concerns_on_rails 1.18.0 → 1.19.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: 191acd17664d56588e54832b2377ede09cf836fe481a0e4c27246adae9f2eb76
4
+ data.tar.gz: 95dc3faff52552da6688961fd51e926d4eb2a041f9974f05ea77a491b698d434
5
5
  SHA512:
6
- metadata.gz: 359c8240a7d15da31c2a3236a69cb75b1cce57dc5fb80869214c64c05b3b7051d2a17ee17e73d5e9aec3321a53ca8a5de7ada2e73fa80f1f4da50e41fbfc6398
7
- data.tar.gz: 6b7388a71ceed19ee2517786f5bffdde6acb2e13506ce4ee00b983ff46293a9479aa91d01e2f6912e50a7eb1944f644534369215274fa2234fd889315e32e6f1
6
+ metadata.gz: f472e83548c6ccd7850c3d3c9994cbb1efac4637210f07f35f69e16ca700aa7f131bed7d24737e825896aa8301cd2786dfe70aa0f563002f1bc8d199056fae28
7
+ data.tar.gz: 6c231481577b2db38df1730cb16ad72c2f06ea7bba9d9dc0730e0c8068dce94597b26b514241e1ebd6be51b91caf4ae75dbb8ea32aa11f1aef9109cd45f3d38b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.19.0 (2026-06-13)
4
+
5
+ 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.
6
+
7
+ ### Added
8
+ - **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.
9
+ - **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.
10
+
3
11
  ## 1.18.0 (2026-06-12)
4
12
 
5
13
  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/README.md CHANGED
@@ -46,6 +46,7 @@ Article.published.without_deleted.find("hello-world")
46
46
  - [Auditable](#-auditable) — single-column change history ("paper_trail-lite")
47
47
  - [Lockable](#-lockable) — failed-attempt tracking + account lockout
48
48
  - [Aliasable](#-aliasable) — full read/write/query aliases for associations
49
+ - [Storable](#-storable) — typed accessors over one JSON settings column ("store_attribute-lite")
49
50
  - **Controller concerns**
50
51
  - [Paginatable](#-paginatable) — offset pagination with headers
51
52
  - [CursorPaginatable](#-cursorpaginatable) — cursor (keyset) pagination with headers
@@ -61,6 +62,7 @@ Article.published.without_deleted.find("hello-world")
61
62
  - [Timezoneable](#-timezoneable) — per-request `Time.zone` from params / header / cookie
62
63
  - [Idempotentable](#-idempotentable) — `Idempotency-Key` request replay (409 on concurrent duplicates)
63
64
  - [WebhookVerifiable](#-webhookverifiable) — HMAC verification for inbound webhooks (Stripe/GitHub/Shopify)
65
+ - [Deprecatable](#-deprecatable) — RFC `Deprecation`/`Sunset` headers + 410 sunset enforcement
64
66
  - [Module paths & namespacing](#-module-paths--namespacing)
65
67
  - [Development](#-development)
66
68
  - [Contributing](#-contributing)
@@ -1039,6 +1041,42 @@ Book.joins(:sections).where(sections: { title: "Intro" })
1039
1041
 
1040
1042
  ---
1041
1043
 
1044
+ ## ⚙️ Storable
1045
+
1046
+ 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.
1047
+
1048
+ ```ruby
1049
+ class Account < ApplicationRecord
1050
+ include ConcernsOnRails::Storable
1051
+
1052
+ storable_by :settings,
1053
+ theme: { type: :string, default: "light", in: %w[light dark] },
1054
+ notifications: { type: :boolean, default: true },
1055
+ items_per_page: { type: :integer, default: 25 },
1056
+ trial_ends_at: { type: :datetime }
1057
+ storable_by :flags, { beta: { type: :boolean, default: false } }, prefix: :flag
1058
+ end
1059
+
1060
+ account.theme # => "light" (virtual default; nothing persisted)
1061
+ account.notifications = "0" # params arrive as strings…
1062
+ account.notifications # => false (…and read back cast)
1063
+ account.notifications? # boolean keys get a predicate
1064
+ account.items_per_page_changed? # per-key dirty (and items_per_page_was)
1065
+ account.reset_theme # drop the key → the default applies again
1066
+ account.flag_beta # affixed accessor
1067
+ ```
1068
+
1069
+ **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.
1070
+
1071
+ **Notes**
1072
+ - 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.
1073
+ - 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).
1074
+ - 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.
1075
+ - 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`.
1076
+ - 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).
1077
+
1078
+ ---
1079
+
1042
1080
  # 🎮 Controller Concerns
1043
1081
 
1044
1082
  Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
@@ -1504,6 +1542,39 @@ end
1504
1542
 
1505
1543
  ---
1506
1544
 
1545
+ ## 🌅 Deprecatable
1546
+
1547
+ 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.
1548
+
1549
+ ```ruby
1550
+ class Api::V1::OrdersController < ApplicationController
1551
+ include ConcernsOnRails::Controllers::Deprecatable
1552
+
1553
+ deprecate_actions :index, :show,
1554
+ deprecated_at: "2026-06-01",
1555
+ sunset_at: "2026-12-31T00:00:00Z",
1556
+ link: "https://docs.example.com/v1-migration",
1557
+ successor: "https://api.example.com/v2/orders",
1558
+ after_sunset: :gone, # default :headers — announce, never block
1559
+ notify: -> { StatsD.increment("api.v1.orders.deprecated") }
1560
+ end
1561
+
1562
+ # Every matching response then carries:
1563
+ # Deprecation: @1780272000
1564
+ # Sunset: Thu, 31 Dec 2026 00:00:00 GMT
1565
+ # Link: <https://docs.example.com/v1-migration>; rel="deprecation", <https://api.example.com/v2/orders>; rel="successor-version"
1566
+ ```
1567
+
1568
+ **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.
1569
+
1570
+ **Notes**
1571
+ - 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.
1572
+ - 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.
1573
+ - 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.
1574
+ - 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.
1575
+
1576
+ ---
1577
+
1507
1578
  ## 🗂️ Module paths & namespacing
1508
1579
 
1509
1580
  Every concern is available under two paths:
@@ -0,0 +1,268 @@
1
+ require "active_support/concern"
2
+ require "active_support/notifications"
3
+ require "date"
4
+ require "time"
5
+
6
+ module ConcernsOnRails
7
+ module Controllers
8
+ # Standards-based API endpoint deprecation — the receiving end of an API
9
+ # retirement plan (how Stripe / GitHub / Zalando sunset versions). Declare
10
+ # which actions are deprecated and the standard signalling headers go out on
11
+ # every response so clients (and their SDKs) can discover the deprecation,
12
+ # the migration docs, the successor endpoint, and the hard cut-off — then
13
+ # optionally enforce a 410 Gone once that cut-off passes.
14
+ #
15
+ # class Api::V1::OrdersController < ApplicationController
16
+ # include ConcernsOnRails::Controllers::Deprecatable
17
+ #
18
+ # deprecate_actions :index, :show,
19
+ # deprecated_at: "2026-06-01",
20
+ # sunset_at: "2026-12-31T00:00:00Z",
21
+ # link: "https://docs.example.com/v1-migration",
22
+ # successor: "https://api.example.com/v2/orders",
23
+ # after_sunset: :gone,
24
+ # notify: -> { StatsD.increment("api.v1.orders.deprecated") }
25
+ # end
26
+ #
27
+ # Headers emitted (always — including on the 410, so the failure is
28
+ # self-documenting):
29
+ # * Deprecation — RFC 9745. The final form is a structured-fields Date
30
+ # item: "@<unix-seconds>" of `deprecated_at`. `header_format: :legacy`
31
+ # emits the still-widely-deployed pre-RFC draft form, the literal "true".
32
+ # * Sunset — RFC 8594. An IMF-fixdate (HTTP-date) via Time#httpdate,
33
+ # NOT ISO 8601 — hand-rolling that is the classic bug.
34
+ # * Link — RFC 8288. rel="deprecation" (the migration doc) and/or
35
+ # rel="successor-version" (the replacement endpoint), APPENDED to any
36
+ # Link header already on the response (pagination / CDN), never clobbered.
37
+ #
38
+ # `sunset_at` is an INSTANT, not a calendar day: a bare date "2026-12-31" is
39
+ # normalised to 00:00 UTC, so the endpoint dies at the START of that day, not
40
+ # end-of-day. Times are parsed eagerly at declaration time and normalised to
41
+ # UTC.
42
+ #
43
+ # THE LAST MATCHING RULE WINS (deliberately the reverse of Idempotentable's
44
+ # first-match): deprecation rules are configuration overrides, not guards, so
45
+ # a V1 base controller's catch-all `deprecate_actions` is naturally
46
+ # overridden by a later, action-specific declaration in a subclass. Exactly
47
+ # one rule applies per request and exactly one Deprecation header is emitted.
48
+ # With no positional actions a rule is a catch-all for the whole controller
49
+ # (the WebhookVerifiable convention).
50
+ #
51
+ # `after_sunset: :gone` (requires `sunset_at`) halts with 410 once the sunset
52
+ # instant is reached (the boundary instant counts as sunset — inclusive). The
53
+ # default `:headers` NEVER blocks, however long past sunset — flip to `:gone`
54
+ # only once metrics show callers have migrated. `on_deprecated_access` is that
55
+ # metrics seam: it instruments "deprecated_endpoint.concerns_on_rails" and
56
+ # runs `notify:`. A raising `notify` propagates on purpose — a broken metrics
57
+ # hook should be loud, not silently swallowed (WebhookVerifiable's stance).
58
+ module Deprecatable
59
+ extend ActiveSupport::Concern
60
+
61
+ LABEL = "ConcernsOnRails::Controllers::Deprecatable".freeze
62
+ VALID_AFTER_SUNSET = %i[headers gone].freeze
63
+ VALID_HEADER_FORMATS = %i[rfc9745 legacy].freeze
64
+ UNPARSEABLE = "could not be parsed (pass a Time, Date, DateTime, or parseable String)".freeze
65
+
66
+ included do
67
+ class_attribute :deprecatable_rules, instance_accessor: false, default: []
68
+ before_action :apply_api_deprecations
69
+ end
70
+
71
+ module ClassMethods
72
+ # Declare a deprecation rule. No positional actions = catch-all for the
73
+ # whole controller. Repeatable; rules accumulate (reassigned, never
74
+ # mutated, so subclasses inherit) and the LAST one matching the current
75
+ # action wins.
76
+ def deprecate_actions(*actions, deprecated_at: nil, sunset_at: nil, link: nil, successor: nil,
77
+ after_sunset: :headers, header_format: :rfc9745, notify: nil)
78
+ actions = actions.flatten.map(&:to_s)
79
+ after_sunset = after_sunset.to_sym
80
+ header_format = header_format.to_sym
81
+
82
+ deprecated_time = parse_deprecation_time(deprecated_at)
83
+ sunset_time = sunset_at.nil? ? nil : parse_deprecation_time(sunset_at)
84
+ validate_deprecate_actions!(deprecated_at: deprecated_at, deprecated_time: deprecated_time,
85
+ sunset_at: sunset_at, sunset_time: sunset_time, link: link,
86
+ successor: successor, after_sunset: after_sunset, notify: notify)
87
+ unless VALID_HEADER_FORMATS.include?(header_format)
88
+ raise ArgumentError, "#{LABEL}: :header_format must be one of #{VALID_HEADER_FORMATS.join(', ')}"
89
+ end
90
+
91
+ rule = { actions: actions, deprecated_at: deprecated_time, sunset_at: sunset_time,
92
+ link: link, successor: successor, after_sunset: after_sunset,
93
+ header_format: header_format, notify: notify }
94
+ self.deprecatable_rules = deprecatable_rules + [rule]
95
+ end
96
+
97
+ private
98
+
99
+ def validate_deprecate_actions!(deprecated_at:, deprecated_time:, sunset_at:, sunset_time:,
100
+ link:, successor:, after_sunset:, notify:)
101
+ raise ArgumentError, "#{LABEL}: :deprecated_at is required" if deprecated_at.nil?
102
+ raise ArgumentError, "#{LABEL}: :deprecated_at #{UNPARSEABLE}" if deprecated_time.nil?
103
+
104
+ validate_deprecation_sunset!(sunset_at, sunset_time, deprecated_time, after_sunset)
105
+ validate_deprecation_link!(:link, link)
106
+ validate_deprecation_link!(:successor, successor)
107
+ raise ArgumentError, "#{LABEL}: :notify must be callable" unless notify.nil? || notify.respond_to?(:call)
108
+ end
109
+
110
+ def validate_deprecation_sunset!(sunset_at, sunset_time, deprecated_time, after_sunset)
111
+ unless VALID_AFTER_SUNSET.include?(after_sunset)
112
+ raise ArgumentError, "#{LABEL}: :after_sunset must be one of #{VALID_AFTER_SUNSET.join(', ')}"
113
+ end
114
+
115
+ unless sunset_at.nil?
116
+ raise ArgumentError, "#{LABEL}: :sunset_at #{UNPARSEABLE}" if sunset_time.nil?
117
+ raise ArgumentError, "#{LABEL}: :sunset_at must be on or after :deprecated_at" if sunset_time < deprecated_time
118
+ end
119
+
120
+ raise ArgumentError, "#{LABEL}: after_sunset: :gone requires :sunset_at" if after_sunset == :gone && sunset_time.nil?
121
+ end
122
+
123
+ def validate_deprecation_link!(name, value)
124
+ return if value.nil?
125
+ return if value.is_a?(String) && !value.strip.empty?
126
+
127
+ raise ArgumentError, "#{LABEL}: :#{name} must be a non-blank String"
128
+ end
129
+
130
+ # Eager parse to a UTC Time. A bare Date (or date-only String) becomes
131
+ # midnight UTC — sunset is an instant at the START of the day.
132
+ def parse_deprecation_time(value)
133
+ case value
134
+ # TimeWithZone listed explicitly: it lies about is_a?(Time) but
135
+ # Module#=== checks the real ancestry, so `when Time` alone would
136
+ # miss it — and Time.current / 1.month.from_now are exactly the
137
+ # values Rails hosts pass.
138
+ when ActiveSupport::TimeWithZone, Time then value.utc
139
+ when DateTime then value.to_time.utc
140
+ when Date then Time.utc(value.year, value.month, value.day)
141
+ when String then parse_deprecation_string(value)
142
+ end
143
+ end
144
+
145
+ def parse_deprecation_string(value)
146
+ return nil if value.strip.empty?
147
+
148
+ # DateTime.parse reads a zoneless string as UTC (+00:00) regardless of
149
+ # the host's system timezone — deterministic — and honours an explicit
150
+ # offset when one is present.
151
+ DateTime.parse(value).to_time.utc
152
+ rescue ArgumentError, TypeError
153
+ nil
154
+ end
155
+ end
156
+
157
+ # before_action entry point. Public so host apps can
158
+ # `skip_before_action :apply_api_deprecations` or override it.
159
+ def apply_api_deprecations
160
+ rule = deprecation_rule_for_action
161
+ return nil unless rule
162
+
163
+ # Order matters: headers go out unconditionally (so even the 410 carries
164
+ # them), THEN we record the access, THEN enforce. Recording before
165
+ # enforcing means metrics still count callers who get the 410.
166
+ emit_deprecation_headers(rule)
167
+ on_deprecated_access(rule)
168
+ enforce_deprecation_sunset(rule)
169
+ end
170
+
171
+ # Public override point + instrumentation seam. Default: emit an
172
+ # ActiveSupport::Notifications event and run the rule's `notify:` callable
173
+ # (instance_exec'd, so it can read controller state). A raising `notify`
174
+ # propagates by design.
175
+ def on_deprecated_access(rule)
176
+ ActiveSupport::Notifications.instrument(
177
+ "deprecated_endpoint.concerns_on_rails",
178
+ controller: deprecation_controller_name, action: deprecation_action_name,
179
+ deprecated_at: rule[:deprecated_at], sunset_at: rule[:sunset_at]
180
+ )
181
+ instance_exec(&rule[:notify]) if rule[:notify]
182
+ end
183
+
184
+ # True when some rule covers the current action.
185
+ def deprecation_active?
186
+ !deprecation_rule_for_action.nil?
187
+ end
188
+
189
+ # True when the matching rule has a sunset_at that the clock has reached.
190
+ def sunset_passed?
191
+ deprecation_sunset_reached?(deprecation_rule_for_action)
192
+ end
193
+
194
+ private
195
+
196
+ def deprecation_rule_for_action
197
+ action = deprecation_action_name
198
+ return nil unless action
199
+
200
+ # Last match wins — see the module comment. reverse_each.find returns the
201
+ # most recently declared rule covering this action (catch-all or not).
202
+ self.class.deprecatable_rules.reverse_each.find do |rule|
203
+ rule[:actions].empty? || rule[:actions].include?(action)
204
+ end
205
+ end
206
+
207
+ def emit_deprecation_headers(rule)
208
+ return unless respond_to?(:response) && response
209
+
210
+ response.set_header("Deprecation", deprecation_header_value(rule))
211
+ response.set_header("Sunset", rule[:sunset_at].httpdate) if rule[:sunset_at]
212
+ emit_deprecation_link_header(rule)
213
+ end
214
+
215
+ def deprecation_header_value(rule)
216
+ # :legacy is the pre-RFC draft form everyone already ships; :rfc9745 is
217
+ # the structured-fields Date item finalised in RFC 9745.
218
+ return "true" if rule[:header_format] == :legacy
219
+
220
+ "@#{rule[:deprecated_at].to_i}"
221
+ end
222
+
223
+ def emit_deprecation_link_header(rule)
224
+ parts = []
225
+ parts << "<#{rule[:link]}>; rel=\"deprecation\"" if rule[:link]
226
+ parts << "<#{rule[:successor]}>; rel=\"successor-version\"" if rule[:successor]
227
+ return if parts.empty?
228
+
229
+ value = parts.join(", ")
230
+ # Append, never clobber: pagination / CDN may already have set Link.
231
+ existing = response.headers["Link"]
232
+ value = "#{existing}, #{value}" unless existing.to_s.empty?
233
+ response.set_header("Link", value)
234
+ end
235
+
236
+ def enforce_deprecation_sunset(rule)
237
+ return unless rule[:after_sunset] == :gone
238
+ return unless deprecation_sunset_reached?(rule)
239
+
240
+ message = "This endpoint was sunset on #{rule[:sunset_at].httpdate}."
241
+ return render_error(message: message, status: :gone, code: "endpoint_sunset") if respond_to?(:render_error)
242
+ return unless respond_to?(:response) && response
243
+
244
+ render json: { success: false, error: { message: message, code: "endpoint_sunset" } }, status: :gone
245
+ end
246
+
247
+ # Inclusive: the boundary instant itself counts as sunset.
248
+ def deprecation_sunset_reached?(rule)
249
+ !!(rule && rule[:sunset_at] && deprecation_now >= rule[:sunset_at])
250
+ end
251
+
252
+ def deprecation_action_name
253
+ respond_to?(:action_name) ? action_name.to_s : nil
254
+ end
255
+
256
+ def deprecation_controller_name
257
+ return controller_path if respond_to?(:controller_path)
258
+
259
+ self.class.name
260
+ end
261
+
262
+ # Single clock seam so travel_to drives everything in specs.
263
+ def deprecation_now
264
+ Time.now.utc
265
+ end
266
+ end
267
+ end
268
+ end
@@ -23,4 +23,5 @@ module ConcernsOnRails
23
23
  Auditable = Models::Auditable
24
24
  Lockable = Models::Lockable
25
25
  Aliasable = Models::Aliasable
26
+ Storable = Models::Storable
26
27
  end
@@ -0,0 +1,436 @@
1
+ require "active_support/concern"
2
+ require "active_support/core_ext/object/deep_dup"
3
+ require "active_model/type"
4
+ require "bigdecimal"
5
+ require "json"
6
+ require "time"
7
+
8
+ module ConcernsOnRails
9
+ module Models
10
+ # Typed, defaulted, optionally-validated accessors over a single JSON (or
11
+ # serialized-text) column ("store_attribute-lite"). Rails' native
12
+ # `store_accessor` is untyped on every supported version (a form-submitted
13
+ # "true" stays the String "true"), ships no defaults, and exposes no
14
+ # per-key dirty methods — the gap that the store_attribute / jsonb_accessor
15
+ # gems exist to fill. This concern closes it with no extra dependency.
16
+ #
17
+ # class Account < ApplicationRecord
18
+ # include ConcernsOnRails::Storable
19
+ #
20
+ # storable_by :settings,
21
+ # theme: { type: :string, default: "light", in: %w[light dark] },
22
+ # notifications: { type: :boolean, default: true },
23
+ # items_per_page: { type: :integer, default: 25 },
24
+ # trial_ends_at: { type: :datetime }
25
+ # storable_by :flags, { beta: { type: :boolean, default: false } }, prefix: :flag
26
+ # end
27
+ #
28
+ # account.theme # => "light" (virtual default; nothing stored yet)
29
+ # account.notifications = "0"
30
+ # account.notifications # => false (cast, not the String "0")
31
+ # account.notifications? # => false (boolean keys get a predicate)
32
+ # account.flag_beta # => false (prefixed accessor)
33
+ # account.items_per_page_changed? # per-key dirty, computed off the column's _was
34
+ # account.reset_theme # drop the key so the reader falls back to the default
35
+ #
36
+ # Per key: `type:` (:string default, :integer, :float, :decimal, :boolean,
37
+ # :date, :datetime, :json), `default:` (a value, or a Proc instance_exec'd
38
+ # per read), `in:` (an enumerable membership set). The macro is repeatable —
39
+ # repeat calls for the SAME column merge keys; different columns are
40
+ # independent. `prefix:`/`suffix:` rename the generated accessors as
41
+ # `<prefix>_<key>_<suffix>`.
42
+ #
43
+ # Notes:
44
+ # * Whole-column dirty: writing one key reassigns (and so dirties) the
45
+ # entire column. Two requests writing different keys of the same row are
46
+ # last-write-wins on the whole hash — there is no per-key merge on save.
47
+ # * nil vs unset: a writer-stored nil (explicit JSON null) reads back as
48
+ # nil and does NOT fall back to the default; `reset_<key>` removes the
49
+ # key entirely so the reader resolves the default again.
50
+ # * :json values are passed through uncast and the reader returns a dup —
51
+ # reassign (`record.config = record.config.merge("k" => 1)`), don't
52
+ # mutate in place, or the write is silently lost.
53
+ # * Read-side casting never raises: corrupt column JSON decodes to {} and
54
+ # ungarbageable values cast to nil (ActiveModel semantics). :decimal is
55
+ # stored precision-safe as a String (BigDecimal), :date/:datetime as
56
+ # ISO8601 strings (datetime in UTC, microsecond precision).
57
+ # * Reserved option names: passing key specs as keyword arguments means a
58
+ # key literally named `prefix` or `suffix` would be swallowed by the
59
+ # affix options — declare those via the positional Hash escape hatch
60
+ # (`storable_by :col, { prefix: { type: :string } }`).
61
+ # * Reach for the store_attribute or jsonb_accessor gems when you need
62
+ # querying into the store, jsonb operators, or store-backed scopes.
63
+ module Storable
64
+ extend ActiveSupport::Concern
65
+
66
+ LABEL = "ConcernsOnRails::Models::Storable".freeze
67
+
68
+ VALID_TYPES = %i[string integer float decimal boolean date datetime json].freeze
69
+ ALLOWED_SPEC_KEYS = %i[type default in].freeze
70
+
71
+ # Reusable ActiveModel casters for the JSON-native types. :decimal,
72
+ # :date and :datetime round-trip through Strings and are handled
73
+ # explicitly; :json is passed through uncast.
74
+ CASTERS = {
75
+ string: ActiveModel::Type::String.new,
76
+ integer: ActiveModel::Type::Integer.new,
77
+ float: ActiveModel::Type::Float.new,
78
+ boolean: ActiveModel::Type::Boolean.new,
79
+ date: ActiveModel::Type::Date.new,
80
+ datetime: ActiveModel::Type::DateTime.new
81
+ }.freeze
82
+
83
+ included do
84
+ # { column => { key => normalized_spec } }. Subclasses inherit and may
85
+ # add keys; every write reassigns deep copies so a parent is never
86
+ # mutated by a child.
87
+ class_attribute :storable_keys, instance_accessor: false, default: {}
88
+ # { generated_method_name => [column, key] } — lets a re-declaration of
89
+ # the same key skip the collision guard while a different key claiming
90
+ # an already-taken accessor still raises.
91
+ class_attribute :storable_owned_methods, instance_accessor: false, default: {}
92
+
93
+ validate :storable_validate_inclusions
94
+ end
95
+
96
+ module ClassMethods
97
+ include ConcernsOnRails::Support::ColumnGuard
98
+
99
+ # Declare typed accessors over `column`. Key specs may arrive as the
100
+ # positional `keys` Hash or as trailing keyword arguments (they are
101
+ # merged); the positional form is the escape hatch for keys literally
102
+ # named `prefix`/`suffix`. See the module docs.
103
+ def storable_by(column, keys = {}, prefix: nil, suffix: nil, **kw_keys)
104
+ column = column.to_sym
105
+ ensure_columns!(LABEL, column)
106
+
107
+ prepared = storable_merge_key_specs(keys, kw_keys).map do |key, raw_spec|
108
+ key = key.to_sym
109
+ [key, storable_normalize_spec(key, raw_spec, prefix, suffix)]
110
+ end
111
+
112
+ storable_install_keys(column, prepared)
113
+ end
114
+
115
+ # Lazily decide — once per class/column, at first write when a DB
116
+ # connection exists — whether the column's attribute type stores a Hash
117
+ # natively (a :json column, or a host-app `serialize`d column) so we can
118
+ # hand it the Hash; everything else gets a generated JSON String.
119
+ def storable_native_hash_column?(column)
120
+ cache = (@storable_native_hash_cache ||= {})
121
+ name = column.to_sym
122
+ return cache[name] if cache.key?(name)
123
+
124
+ cache[name] = storable_detect_native_hash(column)
125
+ end
126
+
127
+ private
128
+
129
+ def storable_merge_key_specs(keys, kw_keys)
130
+ raise ArgumentError, "#{LABEL}: keys must be a Hash of name => spec (got #{keys.class})" unless keys.is_a?(Hash)
131
+
132
+ keys.merge(kw_keys)
133
+ end
134
+
135
+ def storable_assert_spec_shape!(key, raw_spec)
136
+ raise ArgumentError, "#{LABEL}: spec for ':#{key}' must be a Hash, got #{raw_spec.class}" unless raw_spec.is_a?(Hash)
137
+
138
+ unknown = raw_spec.keys.map(&:to_sym) - ALLOWED_SPEC_KEYS
139
+ return if unknown.empty?
140
+
141
+ raise ArgumentError,
142
+ "#{LABEL}: unknown option(s) #{unknown.join(', ')} in spec for ':#{key}' (allowed: #{ALLOWED_SPEC_KEYS.join(', ')})"
143
+ end
144
+
145
+ def storable_normalize_spec(key, raw_spec, prefix, suffix)
146
+ storable_assert_spec_shape!(key, raw_spec)
147
+
148
+ type = (raw_spec[:type] || :string).to_sym
149
+ unless VALID_TYPES.include?(type)
150
+ raise ArgumentError, "#{LABEL}: unknown type ':#{type}' for ':#{key}' (valid: #{VALID_TYPES.join(', ')})"
151
+ end
152
+
153
+ inclusion = raw_spec[:in]
154
+ if !inclusion.nil? && !inclusion.respond_to?(:include?)
155
+ raise ArgumentError, "#{LABEL}: in: for ':#{key}' must be enumerable (respond to #include?)"
156
+ end
157
+
158
+ { type: type, default: raw_spec[:default], in: inclusion,
159
+ accessor: [prefix, key, suffix].compact.join("_").to_sym }
160
+ end
161
+
162
+ # Guard collisions against a working copy of the owners map (so two keys
163
+ # in one call claiming the same accessor are caught too), then commit
164
+ # the merged config and define the accessors.
165
+ def storable_install_keys(column, prepared)
166
+ owners = storable_owned_methods.dup
167
+ prepared.each { |key, spec| storable_guard_collisions!(column, key, spec, owners) }
168
+
169
+ merged = storable_keys.dup
170
+ column_keys = (merged[column] || {}).dup
171
+ prepared.each { |key, spec| column_keys[key] = spec }
172
+ merged[column] = column_keys
173
+ self.storable_keys = merged
174
+ self.storable_owned_methods = owners
175
+
176
+ prepared.each { |key, spec| storable_define_key_methods(column, key, spec) }
177
+ end
178
+
179
+ def storable_guard_collisions!(column, key, spec, owners)
180
+ storable_method_names(spec[:accessor], spec[:type]).each do |method_name|
181
+ owner = owners[method_name]
182
+ if owner
183
+ next if owner == [column, key] # our own re-declaration — merge, don't collide
184
+
185
+ raise storable_collision_error(method_name)
186
+ end
187
+ raise storable_collision_error(method_name) if storable_method_taken?(method_name, spec[:accessor])
188
+
189
+ owners[method_name] = [column, key]
190
+ end
191
+ end
192
+
193
+ # The reader is intentionally listed first so a column-attribute clash
194
+ # reports the bare accessor name.
195
+ def storable_method_names(accessor, type)
196
+ base = accessor.to_s
197
+ names = [base, "#{base}=", "#{base}_changed?", "#{base}_was", "reset_#{base}"]
198
+ names << "#{base}?" if type == :boolean
199
+ names.map(&:to_sym)
200
+ end
201
+
202
+ # A name is taken when it shadows a column's (lazily defined) attribute
203
+ # accessors or any already-defined instance method.
204
+ def storable_method_taken?(method_name, accessor)
205
+ return true if column_names.include?(accessor.to_s)
206
+
207
+ method_defined?(method_name) || private_method_defined?(method_name)
208
+ end
209
+
210
+ def storable_collision_error(method_name)
211
+ ArgumentError.new(
212
+ "#{LABEL}: generated method '#{method_name}' collides with an existing method or column; " \
213
+ "pass prefix: or suffix: to rename the accessors"
214
+ )
215
+ end
216
+
217
+ # Methods look the spec up at call time (not via closure) so a later
218
+ # merge that changes a key's type/default takes effect without redefining.
219
+ def storable_define_key_methods(column, key, spec)
220
+ base = spec[:accessor].to_s
221
+
222
+ define_method(base) { storable_get(column, key) }
223
+ define_method("#{base}=") { |value| storable_set(column, key, value) }
224
+ define_method("#{base}_changed?") { storable_key_changed?(column, key) }
225
+ define_method("#{base}_was") { storable_key_was(column, key) }
226
+ define_method("reset_#{base}") { storable_reset(column, key) }
227
+ define_method("#{base}?") { storable_get(column, key) == true } if spec[:type] == :boolean
228
+ end
229
+
230
+ def storable_detect_native_hash(column)
231
+ name = column.to_s
232
+ type = type_for_attribute(name)
233
+ return true if defined?(ActiveRecord::Type::Serialized) && type.is_a?(ActiveRecord::Type::Serialized)
234
+
235
+ col = columns_hash[name]
236
+ !col.nil? && col.type == :json
237
+ rescue StandardError
238
+ false
239
+ end
240
+ end
241
+
242
+ # ---- readers / writers (the generated accessors delegate here) ----
243
+
244
+ private
245
+
246
+ def storable_get(column, key)
247
+ spec = storable_spec(column, key)
248
+ storable_resolve(spec, storable_decode(self[column]), key)
249
+ end
250
+
251
+ def storable_set(column, key, value)
252
+ spec = storable_spec(column, key)
253
+ hash = storable_decode(self[column]).dup
254
+ hash[key.to_s] = storable_cast_write(spec[:type], value)
255
+ storable_assign(column, hash)
256
+ end
257
+
258
+ # Per-key dirty: decode the column's own _was value and compare the cast
259
+ # per-key values. After a save (dirty reset) _was equals the current value,
260
+ # so the key reads as unchanged.
261
+ def storable_key_changed?(column, key)
262
+ storable_key_was(column, key) != storable_get(column, key)
263
+ end
264
+
265
+ def storable_key_was(column, key)
266
+ spec = storable_spec(column, key)
267
+ storable_resolve(spec, storable_decode(attribute_was(column.to_s)), key)
268
+ end
269
+
270
+ # In-memory only (no save) — hence no bang. Removing the key lets the
271
+ # reader fall back to the default again.
272
+ def storable_reset(column, key)
273
+ hash = storable_decode(self[column])
274
+ return unless hash.key?(key.to_s)
275
+
276
+ new_hash = hash.dup
277
+ new_hash.delete(key.to_s)
278
+ storable_assign(column, new_hash)
279
+ end
280
+
281
+ def storable_spec(column, key)
282
+ self.class.storable_keys.fetch(column).fetch(key)
283
+ end
284
+
285
+ # absent -> default; present-but-nil -> nil (never the default); present ->
286
+ # cast through the declared type.
287
+ def storable_resolve(spec, hash, key)
288
+ skey = key.to_s
289
+ return storable_default(spec) unless hash.key?(skey)
290
+
291
+ raw = hash[skey]
292
+ return nil if raw.nil?
293
+
294
+ storable_cast_read(spec[:type], raw)
295
+ end
296
+
297
+ # A Proc default is instance_exec'd per call; a mutable Hash/Array default
298
+ # is deep-duped per call so one instance's mutation never leaks into another.
299
+ def storable_default(spec)
300
+ default = spec[:default]
301
+ return instance_exec(&default) if default.is_a?(Proc)
302
+
303
+ case default
304
+ when Hash, Array then default.deep_dup
305
+ else default
306
+ end
307
+ end
308
+
309
+ # ---- storage codec ----
310
+
311
+ # A native-json Hash is used as-is; a String is parsed tolerantly; anything
312
+ # else (nil, an array, a stray scalar) decodes to {}.
313
+ def storable_decode(raw)
314
+ case raw
315
+ when Hash then raw
316
+ when String then storable_parse(raw)
317
+ else {}
318
+ end
319
+ end
320
+
321
+ def storable_parse(string)
322
+ return {} if string.strip.empty?
323
+
324
+ parsed = JSON.parse(string)
325
+ parsed.is_a?(Hash) ? parsed : {}
326
+ rescue JSON::ParserError
327
+ {}
328
+ end
329
+
330
+ # Reassigning the whole attribute is what marks the column dirty. Hand a
331
+ # Hash to native/serialized columns; otherwise generate the JSON String.
332
+ def storable_assign(column, hash)
333
+ self[column] = if self.class.storable_native_hash_column?(column)
334
+ hash
335
+ else
336
+ JSON.generate(hash)
337
+ end
338
+ end
339
+
340
+ # ---- casting ----
341
+
342
+ def storable_cast_read(type, raw)
343
+ case type
344
+ when :json then raw.deep_dup
345
+ when :decimal then storable_read_decimal(raw)
346
+ when :date then CASTERS[:date].cast(raw)
347
+ when :datetime then storable_read_time(raw)
348
+ else CASTERS[type].cast(raw)
349
+ end
350
+ rescue StandardError
351
+ # ActiveModel casting tolerates garbage already; BigDecimal()/Time.iso8601
352
+ # do not, so swallow and follow the "cast to nil" convention.
353
+ nil
354
+ end
355
+
356
+ def storable_read_decimal(raw)
357
+ return raw if raw.is_a?(BigDecimal)
358
+
359
+ BigDecimal(raw.to_s)
360
+ end
361
+
362
+ def storable_read_time(raw)
363
+ return raw if raw.is_a?(Time)
364
+
365
+ Time.iso8601(raw.to_s)
366
+ end
367
+
368
+ def storable_cast_write(type, value)
369
+ case type
370
+ when :json then value
371
+ when :decimal then storable_write_decimal(value)
372
+ when :date then storable_write_date(value)
373
+ when :datetime then storable_write_datetime(value)
374
+ else CASTERS[type].cast(value)
375
+ end
376
+ end
377
+
378
+ # Precision-safe String (the Auditable precedent): BigDecimal#to_s("F").
379
+ def storable_write_decimal(value)
380
+ return nil if value.nil?
381
+
382
+ big = value.is_a?(BigDecimal) ? value : BigDecimal(value.to_s)
383
+ big.to_s("F")
384
+ rescue ArgumentError, TypeError
385
+ nil
386
+ end
387
+
388
+ def storable_write_date(value)
389
+ CASTERS[:date].cast(value)&.iso8601
390
+ end
391
+
392
+ # UTC iso8601(6): microsecond precision, the lesson CursorPaginatable learned.
393
+ def storable_write_datetime(value)
394
+ storable_coerce_time(value)&.utc&.iso8601(6)
395
+ end
396
+
397
+ # A bare Date becomes midnight UTC (deterministic — Date#to_time would
398
+ # anchor to the host's zone).
399
+ def storable_coerce_time(value)
400
+ case value
401
+ when nil then nil
402
+ when ActiveSupport::TimeWithZone, Time then value
403
+ when DateTime then value.to_time
404
+ when Date then Time.utc(value.year, value.month, value.day)
405
+ else CASTERS[:datetime].cast(value)
406
+ end
407
+ rescue ArgumentError, TypeError
408
+ nil
409
+ end
410
+
411
+ # ---- validation ----
412
+
413
+ # One pass adds an inclusion error per `in:`-constrained key whose stored
414
+ # value is present and non-nil but casts outside the allowed set. Absent
415
+ # and nil values pass (compose with a presence validator if you need them).
416
+ def storable_validate_inclusions
417
+ self.class.storable_keys.each do |column, keys|
418
+ decoded = storable_decode(self[column])
419
+ keys.each do |key, spec|
420
+ allowed = spec[:in]
421
+ next unless allowed
422
+
423
+ skey = key.to_s
424
+ next unless decoded.key?(skey)
425
+
426
+ raw = decoded[skey]
427
+ next if raw.nil?
428
+
429
+ value = storable_cast_read(spec[:type], raw)
430
+ errors.add(spec[:accessor], "is not included in the list") unless allowed.include?(value)
431
+ end
432
+ end
433
+ end
434
+ end
435
+ end
436
+ end
@@ -1,3 +1,3 @@
1
1
  module ConcernsOnRails
2
- VERSION = "1.18.0".freeze
2
+ VERSION = "1.19.0".freeze
3
3
  end
@@ -50,6 +50,7 @@ require "concerns_on_rails/models/monetizable"
50
50
  require "concerns_on_rails/models/auditable"
51
51
  require "concerns_on_rails/models/lockable"
52
52
  require "concerns_on_rails/models/aliasable"
53
+ require "concerns_on_rails/models/storable"
53
54
 
54
55
  # Controller concerns
55
56
  require "concerns_on_rails/controllers/paginatable"
@@ -66,6 +67,7 @@ require "concerns_on_rails/controllers/timezoneable"
66
67
  require "concerns_on_rails/controllers/idempotentable"
67
68
  require "concerns_on_rails/controllers/webhook_verifiable"
68
69
  require "concerns_on_rails/controllers/cursor_paginatable"
70
+ require "concerns_on_rails/controllers/deprecatable"
69
71
 
70
72
  # Backwards compatibility (top-level aliases for pre-1.6 module paths)
71
73
  require "concerns_on_rails/legacy_aliases"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: concerns_on_rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.18.0
4
+ version: 1.19.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ethan Nguyen
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-12 00:00:00.000000000 Z
11
+ date: 2026-06-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -72,6 +72,7 @@ files:
72
72
  - lib/concerns_on_rails.rb
73
73
  - lib/concerns_on_rails/controllers/authorizable.rb
74
74
  - lib/concerns_on_rails/controllers/cursor_paginatable.rb
75
+ - lib/concerns_on_rails/controllers/deprecatable.rb
75
76
  - lib/concerns_on_rails/controllers/error_handleable.rb
76
77
  - lib/concerns_on_rails/controllers/filterable.rb
77
78
  - lib/concerns_on_rails/controllers/idempotentable.rb
@@ -104,6 +105,7 @@ files:
104
105
  - lib/concerns_on_rails/models/soft_deletable.rb
105
106
  - lib/concerns_on_rails/models/sortable.rb
106
107
  - lib/concerns_on_rails/models/stateable.rb
108
+ - lib/concerns_on_rails/models/storable.rb
107
109
  - lib/concerns_on_rails/models/taggable.rb
108
110
  - lib/concerns_on_rails/models/tokenizable.rb
109
111
  - lib/concerns_on_rails/support/address_data.rb