concerns_on_rails 1.28.5 → 1.28.6

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: 5c6fcd158fd0dce6d1744e9ad6be5d921bf283e921e717a8327f04f53e8668f4
4
- data.tar.gz: fe3b93a06be3a0cbb4b0e1904a976393b77d24c4075f1d8dccb98e13d3ac6597
3
+ metadata.gz: d01a41891ae267bab705b85ab63d6555e7057719a218738bd82c858c63febfa9
4
+ data.tar.gz: 3bcaebd299e65af41029baebb90ba1670a3502fde222071bffeeef86a4e1fb7b
5
5
  SHA512:
6
- metadata.gz: 1a113631bd4810decc66aad97ba5fc9fe8866d92dcfd1ab1908c2cdafe7e990e961c3655933ad124ae8275f1a199c4275a7ba72a06cc172ecf248e2825eb42de
7
- data.tar.gz: 7102f8a5cb83e21eabe46066f7a62956adf0950dbe926dc8706c9abd217a87577b6004d625c71d179dc2cf4083dc7fdefd25b47883e1a085fee1ec5bb691b41c
6
+ metadata.gz: 602d50edd8fd84224c73a6ddc09e756bf322f4b3b4e526d34e55774caa88594c54c3ba05d1920005c2152986035b230e2f982e5b6916c2a3a2ec402e1ac2c3c6
7
+ data.tar.gz: 9ab3f9c635ab132b284d673861c3a3a8282f93aa370f6f75cb1890a1205b9e3c924705b52afd7d182a94fb5247fdc38f51e94bcc4d2a0b1c8de39ea53ee03b54
data/CHANGELOG.md CHANGED
@@ -1,5 +1,114 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.28.6 (2026-09-18)
4
+
5
+ Ten feature PRs deepening existing concerns, released as a patch by request: no new
6
+ concerns and no dependency changes, though several add an optional column or option.
7
+ Every PR was reviewed before merge and carries the review's fixes; the `### Changed`
8
+ and `### Fixed` sections below are those fixes, and they are the ones to read — a few
9
+ tighten validation or start raising where the code used to accept bad input silently.
10
+ Eight further PRs were reviewed in the same pass and held back: they carry defects
11
+ that need more than a patch-sized change.
12
+
13
+ ### Added
14
+ - **Models::Maskable**: `masked_attributes` (every declared field masked, keyed like
15
+ `attributes`) and a `masked:` serialization option — `as_json(masked: true)` /
16
+ `to_json(masked: true)` / `serializable_hash(masked: true)` swap the declared fields
17
+ for their masked forms (`masked: [:email]` for a subset; undeclared fields raise).
18
+ Composes with `only:`/`except:`/`methods:`/`include:`; plain serialization is
19
+ unchanged. (#65)
20
+ - **Models::Activatable**: `before_activate` / `after_activate` / `before_deactivate` /
21
+ `after_deactivate` hooks (one transaction with the write; a raising after-hook rolls
22
+ back, a failed update skips it; overriding one moves that direction's batch verb to
23
+ the per-record path) and `activatable_by timestamps:` (`true` → `activated_at` /
24
+ `deactivated_at` stamped on each transition, or a Hash to rename/drop a side). The
25
+ batch fast path stamps too. (#78)
26
+ - **Models::Hashable**: `prefix:` prepends a literal to generated values (Stripe-style
27
+ public IDs such as `ord_k7m3pq9a`; string types only), and `to_param: true` makes the
28
+ hashed field the URL parameter (falls back to the id while blank). Both validated at
29
+ class load. (#70)
30
+ - **Models::Tokenizable**: `tokenizable_by … expires_in:` gives a token a lifetime —
31
+ `<field>_expires_at` is stamped on every generation, `authenticate_by_<field>` refuses
32
+ an expired token, `<field>_expired?` and the `<field>_expired` scope report it. New
33
+ `consume_<field>(value)` (every field) authenticates and revokes in one race-safe step
34
+ for single-use tokens. (#51)
35
+ - **Models::Auditable**: `ConcernsOnRails.setup { |c| c.audit_actor = -> { Current.user&.id } }`
36
+ sets the `"by"` actor once for every audited model that passes no `actor:` (resolved
37
+ per save, `instance_exec`'d on the record); `actor: false` opts a model out, and
38
+ `actor: :method_name` stamps a record method's value. (#50)
39
+ - **Models::CounterCacheable**: `recount_counter_caches!(association, parents:)` repairs
40
+ only the given parents (ids, records or a relation) — locked, zeroed and re-tallied in
41
+ one transaction, other rows untouched — so a post-import fix is O(their children).
42
+ Empty `parents:` is a no-op; the association is required when more than one is
43
+ declared. (#75)
44
+ - **Controllers::Includable**: nested include allow-lists — `includable :author,
45
+ comments: :author` accepts `?include=comments.author` (a path must match every
46
+ segment). `requested_includes(as: :query | :paths | :json)` returns the sanitized
47
+ includes for `includes`/`preload`, JSON:API serializers, or `as_json(include:)`;
48
+ `requested_include_paths` exposes the dotted paths. `default:` loads named paths when
49
+ `?include` is absent (blank opts out); `strategy:` picks
50
+ `includes`/`preload`/`eager_load`. New `Support::IncludeTree`. (#69)
51
+ - **Controllers::Idempotentable**: replays now carry the original response's `Location`,
52
+ `Content-Location`, `ETag`, `Last-Modified` and `Link` headers, so a retried `create`
53
+ still says where the resource lives. `idempotent_actions … headers:` tunes the
54
+ allow-list (`[]` to capture none). Records written before this release replay
55
+ unchanged. (#47)
56
+ - **Controllers::Throttleable**: `throttle_by … if:/unless:` per-request skip conditions
57
+ (Symbol method or callable; both must pass), mirroring Rails 7.2's `rate_limit`. A
58
+ throttled request instruments `rate_limited.concerns_on_rails` (rule, discriminator,
59
+ count/limit/period, reset_at/retry_after, controller, action) through the new public
60
+ `on_rate_limited(rule, result)` hook. (#44)
61
+ - **Controllers::Respondable**: `respondable_by error_format: :problem_details,
62
+ problem_type_base:` makes `render_error` emit RFC 9457 `application/problem+json`
63
+ documents (`type`, `title`, `status`, `detail`, `instance`, plus `code`/`errors`
64
+ extensions). Every concern that renders errors through Respondable follows, so the
65
+ switch is app-wide. The default stays `:envelope`. (#57)
66
+
67
+ ### Changed
68
+ - **Controllers::Throttleable**: with several applicable rules the `X-RateLimit-*`
69
+ headers now describe the tightest passing rule (fewest remaining) instead of the last
70
+ one declared, and a tie on remaining is broken by the rule that resets *last* — so the
71
+ advertised `X-RateLimit-Reset` can no longer promise a 60-second wait when an hourly
72
+ rule is the real budget. `result[:discriminator]` is now available to
73
+ `throttled_response`. Note it is raw personal data (an IP or a user id) in the
74
+ instrumentation payload too; override `on_rate_limited` to hash or drop it. (#44)
75
+ - **Controllers::Throttleable**: `if:`/`unless:` conditions are now arity-aware — a
76
+ one-argument callable receives the controller, a zero-arity Proc is still
77
+ `instance_exec`'d. Previously a `->(c) { … }` condition raised `ArgumentError` on
78
+ every request. (#44)
79
+ - **Models::Auditable**: an explicit `actor: nil` now keeps meaning "never record who"
80
+ and no longer falls through to the gem-wide `config.audit_actor`; only omitting
81
+ `actor:` takes the fallback. `config.audit_actor` accepts a non-Proc callable and
82
+ `false` (a synonym for nil), and rejects a lambda that demands arguments at
83
+ configuration time rather than raising inside `before_save`. (#50)
84
+ - **Models::CounterCacheable**: `recount_counter_caches!` now raises `ArgumentError`
85
+ for an association with no declared counter, instead of returning `{}` as a silent
86
+ success; and a scoped repair locks the named parent rows before tallying, so a
87
+ concurrent child write can no longer be lost. (#75)
88
+ - **Controllers::Idempotentable**: response-header capture is case-insensitive, so an
89
+ app that sets `location` rather than `Location` (correct under Rack 3) is captured on
90
+ every supported Rails version, not only 7.1+. `idempotency_scope` is now a documented
91
+ public override point — the default namespace has no per-principal component, so two
92
+ users sharing a client-chosen key can be served each other's cached response. (#47)
93
+ - **Controllers::Respondable**: the problem-details media type is emitted as bare
94
+ `application/problem+json`, with no `charset` parameter — RFC 9457 registers none, and
95
+ strict clients reject the parameterised form. (#57)
96
+
97
+ ### Fixed
98
+ - **Models::Activatable**: lifecycle hooks are invoked with `send`, so a `private` hook
99
+ override runs instead of raising `NoMethodError` — which in a batch verb aborted and
100
+ rolled back the whole run. (#78)
101
+ - **Models::Hashable**: the `to_param: true` / Sluggable conflict now raises in *either*
102
+ declaration order. With Hashable declared first, friendly_id's `to_param` used to land
103
+ above Hashable's and silently win, so `to_param: true` did nothing. (#70)
104
+ - **Models::Tokenizable**: `expires_in:` now requires an `ActiveSupport::Duration` or a
105
+ `Numeric`. `expires_in: 2.hours.from_now` was accepted as ~1.8 billion seconds (tokens
106
+ effectively never expired) and `expires_in: "2 hours"` as 2 seconds. A model missing
107
+ both token columns now reports them in one error. (#51)
108
+ - **Controllers::Includable**: `requested_includes(as: :paths)` returns a fresh array on
109
+ the `default:` path; it previously handed back the class attribute itself, so a caller
110
+ mutating the result corrupted every later request in the process. (#69)
111
+
3
112
  ## 1.28.5 (2026-09-17)
4
113
 
5
114
  Eight feature PRs deepening existing concerns, released as a patch by request:
data/README.md CHANGED
@@ -148,7 +148,7 @@ across all 43 concerns — press <kbd>/</kbd> and type.
148
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
149
149
  - **Schema-validated configuration** — every macro checks that the configured columns exist and raises `ArgumentError` early — listing *every* missing column at once, with one ready-to-paste `rails generate migration` command that adds them all
150
150
  - **Composable** — concerns are independent; mix and match per model
151
- - **Tested like an app, not a snippet** — **1,522 RSpec examples** run against a real database on every CI build
151
+ - **Tested like an app, not a snippet** — **1,624 RSpec examples** run against a real database on every CI build
152
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
153
153
 
154
154
  ---
@@ -183,6 +183,7 @@ instead of per controller class:
183
183
  # config/initializers/concerns_on_rails.rb
184
184
  ConcernsOnRails.setup do |config|
185
185
  config.cache_store = -> { Rails.cache } # fallback for Throttleable / Idempotentable
186
+ config.audit_actor = -> { Current.user&.id } # fallback "by" for every Auditable model
186
187
  end
187
188
 
188
189
  # Encryptable's key lives in its own config (see the Encryptable section):
@@ -541,7 +542,7 @@ end
541
542
 
542
543
  ## 🔐 Hashable
543
544
 
544
- Auto-generate random values on create — tokens, codes, UUIDs, or anything from a custom alphabet.
545
+ Auto-generate random values on create — tokens, codes, UUIDs, or anything from a custom alphabet — and, optionally, use them as the public ID in your URLs.
545
546
 
546
547
  ```ruby
547
548
  class Order < ApplicationRecord
@@ -570,13 +571,24 @@ hashable_by :external_id, type: :uuid
570
571
  hashable_by :code, type: :integer, length: 6
571
572
  hashable_by :code, type: :custom, length: 8,
572
573
  alphabet: "ABCDEFGHJKMNPQRSTUVWXYZ23456789" # Crockford-style, no ambiguous chars
574
+
575
+ # Public IDs: a Stripe-style prefix and the value as the URL parameter
576
+ hashable_by :public_id, type: :custom, length: 14, prefix: "ord_", unique: true, to_param: true,
577
+ alphabet: "abcdefghijklmnopqrstuvwxyz0123456789"
578
+ order.public_id # => "ord_k7m3pq9a2x5n8v"
579
+ order_path(order) # => "/orders/ord_k7m3pq9a2x5n8v"
580
+ Order.find_by!(public_id: params[:id])
581
+ # while backfilling, accept both shapes — to_param falls back to the integer id when public_id is blank
582
+ Order.find_by(public_id: params[:id]) || Order.find(params[:id])
573
583
  ```
574
584
 
575
585
  **Notes**
576
586
  - Auto-assigns in `before_create` only when the field is blank — callers can pass an explicit value.
577
587
  - A `regenerate_<field>!` instance method is defined dynamically.
588
+ - `prefix:` is prepended to every generated value (string types only — not `:integer`); the uniqueness check sees the full prefixed value.
589
+ - `to_param: true` overrides `to_param` to return the hashed field, falling back to the id while it is blank — pair it with `find_by!(field: params[:id])`, or with `find_by(field: params[:id]) || find(params[:id])` while a backfill is still in flight (a blank field puts an integer in the URL). Raises at class load alongside Sluggable, in either declaration order — friendly_id overrides `to_param` too.
590
+ - `unique: true` prechecks for collisions and retries a bounded number of times (still add a unique index — that is the real guarantee).
578
591
  - For fixed-width numeric codes (e.g. `000042`), use a **string** column — integer columns drop leading zeros.
579
- - No uniqueness retry is built in. For collision-prone configs (short integer codes), add a unique index and rescue at the app level.
580
592
  - If your model has `validates :<field>, presence: true`, switch this concern's hook to `before_validation` in your model — it uses `before_create` by default.
581
593
 
582
594
  ---
@@ -795,6 +807,10 @@ class Subscription < ApplicationRecord
795
807
 
796
808
  activatable_by # defaults to :active
797
809
  # activatable_by :enabled # custom column name
810
+ # activatable_by timestamps: true # stamps activated_at / deactivated_at
811
+ # activatable_by timestamps: { activated_at: :enabled_at, deactivated_at: nil }
812
+
813
+ def after_deactivate = Billing.pause!(self) # before/after_activate, before/after_deactivate hooks
798
814
  end
799
815
 
800
816
  sub = Subscription.create!(active: true)
@@ -814,18 +830,23 @@ Subscription.inactive.activate_all # => 12
814
830
  Subscription.active.deactivate_all # => 3
815
831
  ```
816
832
 
817
- Both target the relation, return an Integer count, and run in a transaction. With
818
- `activate!`/`deactivate!` unoverridden and no validations on the model — neither
833
+ Both target the relation, return an Integer count, and run in a transaction. Gating is per
834
+ direction: with `activate!`, `before_activate` and `after_activate` unoverridden and no
835
+ validations on the model — neither
819
836
  `validates`/`validates_with`, a custom `validate :method`, nor an association's autosave
820
837
  validation (a bare `has_many` registers one, so most models with associations take the
821
- streaming path) — they collapse to a single
822
- `UPDATE`, which bumps `updated_at` exactly as the per-record path does; otherwise they stream
823
- per record so validations still run, and a record that fails to save raises
824
- `ActiveRecord::RecordNotSaved` and rolls the whole batch back. `toggle_active!`'s row lock has
838
+ streaming path) — `activate_all` collapses to a single
839
+ `UPDATE`, which bumps `updated_at` exactly as the per-record path does; otherwise it streams
840
+ per record so the hooks and validations still run, and a record that fails to save raises
841
+ `ActiveRecord::RecordNotSaved` and rolls the whole batch back. `deactivate_all` is gated the
842
+ same way by `deactivate!`/`before_deactivate`/`after_deactivate`, so overriding only
843
+ `after_deactivate` leaves `activate_all` on the fast path. `toggle_active!`'s row lock has
825
844
  no batch analogue.
826
845
 
827
846
  **Notes**
828
847
  - `NULL` is treated as inactive (same convention as most apps' "unset = off").
848
+ - Hooks (`before_activate` / `after_activate` / `before_deactivate` / `after_deactivate`) share one transaction with the write: a raising after-hook rolls the flip back, a failed `update` (validation) skips the after-hook and returns `false`. `toggle_active!` and the batch verbs go through the same path.
849
+ - `timestamps: true` stamps `activated_at` on activate and `deactivated_at` on deactivate (the other column keeps its last value, so you can see both the last activation and the last deactivation); a Hash renames either column or drops a side with `nil`. The stamp columns must already exist — `activatable_by` checks that at declaration and raises `ArgumentError` otherwise; the `datetime` type itself is not enforced, it only types the migration hint in that error.
829
850
  - The configured column must exist; `activatable_by` raises `ArgumentError` otherwise.
830
851
  - `SoftDeletable` also defines a `.active` scope (alias of `.without_deleted`). If both concerns are included on the same model, the later one wins — include the one whose `.active` semantics you want last, or stick to one of them.
831
852
 
@@ -840,19 +861,22 @@ class User < ApplicationRecord
840
861
  include ConcernsOnRails::Tokenizable
841
862
 
842
863
  tokenizable_by :api_token # 32-char URL-safe
843
- tokenizable_by :reset_password_token, length: 24
864
+ tokenizable_by :reset_password_token, length: 24, expires_in: 2.hours # needs reset_password_token_expires_at
844
865
  tokenizable_by :invite_code, type: :alphanumeric, length: 8
845
866
  end
846
867
 
847
- user = User.create! # all three tokens auto-generated
868
+ user = User.create! # all three tokens auto-generated (+ the reset token's expiry stamped)
848
869
  user.api_token # => "k3Jf...g2" (32 URL-safe chars)
849
870
  user.api_token? # => true
871
+ user.reset_password_token_expired? # => false, until reset_password_token_expires_at passes
850
872
 
851
- user.regenerate_api_token! # rotates and persists
852
- user.revoke_api_token! # nils the column
873
+ user.regenerate_api_token! # rotates and persists (an expiring field gets a fresh expiry too)
874
+ user.revoke_api_token! # nils the column (and the expiry)
853
875
 
854
876
  User.find_by_api_token(token) # Rails default
855
- User.authenticate_by_api_token(token) # timing-safe; returns user or nil
877
+ User.authenticate_by_api_token(token) # timing-safe; returns user or nil — nil for an EXPIRED token
878
+ User.consume_reset_password_token(token) # single use: authenticate AND revoke atomically; nil the second time
879
+ User.reset_password_token_expired # scope: rows whose expiry has passed (cleanup jobs)
856
880
  ```
857
881
 
858
882
  **Options**
@@ -861,12 +885,15 @@ User.authenticate_by_api_token(token) # timing-safe; returns user or nil
861
885
  | -------- | ----------- | ------------------------------------------------------------- |
862
886
  | `type:` | `:urlsafe` | One of `:urlsafe`, `:hex`, `:alphanumeric`, `:numeric` |
863
887
  | `length:`| `32` | Character length of the generated token |
888
+ | `expires_in:` | `nil` | A `Duration`/seconds. Stamps `<field>_expires_at` (a `datetime` column you add) on every generation; `authenticate_by_`/`consume_` refuse a stale token; adds `<field>_expired?` and the `<field>_expired` scope |
864
889
 
865
890
  **Notes**
866
891
  - URL-safe by default (`A–Z`, `a–z`, `0–9`, `-`, `_`) — drop straight into URLs and headers.
867
892
  - Caller-supplied values are respected: `User.create!(api_token: "preset")` won't be overwritten.
868
893
  - Generation does a best-effort uniqueness check before insert and retries up to 10 times. Pair with a `unique` DB index for real safety, especially for short alphanumeric/numeric codes.
869
- - `.authenticate_by_<field>` uses `ActiveSupport::SecurityUtils.secure_compare` to avoid leaking partial matches via response timing.
894
+ - `.authenticate_by_<field>` uses `ActiveSupport::SecurityUtils.secure_compare` to avoid leaking partial matches via response timing, and returns `nil` once an `expires_in:` token has expired.
895
+ - `.consume_<field>(value)` (every field) is the single-use verb — password resets, invite codes, magic links: it authenticates, then revokes with a **conditional `UPDATE`** keyed on the token still being present, so two concurrent consumers cannot both succeed; the loser gets `nil`. An expired token is refused and left in place.
896
+ - A caller-supplied token on an `expires_in:` field gets the configured lifetime **on create** unless the caller also sets `<field>_expires_at`. Assigning one to an already-persisted row (`user.update!(reset_password_token: "preset")`) stamps nothing, and a row whose expiry is `nil` never expires — rotate with `regenerate_<field>!` (which stamps a fresh expiry) or set `<field>_expires_at` yourself.
870
897
  - Distinct from `Hashable`: Hashable handles a single random field; Tokenizable focuses on security tokens (multi-field, URL-safe default, timing-safe lookup, revocation).
871
898
 
872
899
  ---
@@ -1208,6 +1235,18 @@ end
1208
1235
 
1209
1236
  `mask:` sets the mask character (default `*`). Nil and non-string values pass through untouched. To strip dangerous HTML instead, see [Sanitizable](#-sanitizable).
1210
1237
 
1238
+ **Serialization** — mask in the response, not just in the view
1239
+
1240
+ ```ruby
1241
+ user.masked_attributes # => { "email" => "j****@example.com", "card" => "**** **** **** 4242" }
1242
+ user.as_json(masked: true) # every declared field swapped for its masked form, the rest raw
1243
+ user.as_json(masked: [:email]) # just these fields (undeclared ones raise)
1244
+ user.to_json(masked: true, only: %i[id email]) # composes with only:/except:/methods:/include:
1245
+ render json: users.map { |u| u.as_json(masked: true) }
1246
+ ```
1247
+
1248
+ Plain `as_json` / `to_json` are untouched, so nothing changes until you ask.
1249
+
1211
1250
  ---
1212
1251
 
1213
1252
  ## 💰 Monetizable
@@ -1256,10 +1295,13 @@ class Product < ApplicationRecord
1256
1295
 
1257
1296
  auditable_by :price, :status # default column :audit_log
1258
1297
  # auditable_by :price, into: :history,
1259
- # actor: -> { Current.user&.email }, # stamps "by" on each entry
1298
+ # actor: -> { Current.user&.email }, # stamps "by" on each entry (or actor: :updated_by_id)
1260
1299
  # max_entries: 50 # keep the newest 50
1261
1300
  end
1262
1301
 
1302
+ # Or set the actor once for every audited model — models that omit actor: use it, actor: nil/false opts out:
1303
+ ConcernsOnRails.setup { |config| config.audit_actor = -> { Current.user&.id } }
1304
+
1263
1305
  product.update!(price: 200)
1264
1306
  product.audit_trail
1265
1307
  # => [{"field"=>"price", "from"=>100, "to"=>200, "at"=>"2026-06-10T12:34:56Z", "by"=>"admin@shop.com"}]
@@ -1270,7 +1312,7 @@ product.clear_audit_trail! # wipe the column (skips callbacks)
1270
1312
 
1271
1313
  One entry is recorded **per changed field per save** (creates record `"from" => nil`), appended in the same `INSERT`/`UPDATE` via `before_save` — zero extra queries.
1272
1314
 
1273
- **Options**: `into:` (`:audit_log`), `actor:` (callable, `instance_exec`'d on the record; `"by"` omitted when absent), `max_entries:` (`200`; keeps the newest N, `nil` = unlimited), `max_value_length:` (`nil`; truncates long String `from`/`to` values to the first N characters + `…`).
1315
+ **Options**: `into:` (`:audit_log`), `actor:` (a Proc `instance_exec`'d on the record, any other callable `#call`ed, or a Symbol naming a record method such as `:updated_by_id`; omit it to take the gem-wide `config.audit_actor`, an explicit `nil`/`false` opts out of that; `"by"` omitted when it resolves to nil), `max_entries:` (`200`; keeps the newest N, `nil` = unlimited), `max_value_length:` (`nil`; truncates long String `from`/`to` values to the first N characters + `…`).
1274
1316
 
1275
1317
  **Notes**
1276
1318
  - Writes that skip callbacks (`update_column(s)`, `touch`, `increment!`) are **not** audited; `save(validate: false)` is.
@@ -1410,6 +1452,7 @@ end
1410
1452
 
1411
1453
  post.comments_count # maintained on create / destroy / update
1412
1454
  Comment.recount_counter_caches! # repair drift / backfill every counter
1455
+ Comment.recount_counter_caches!(:post, parents: imported_posts) # repair just these parents (ids, records or a relation)
1413
1456
  ```
1414
1457
 
1415
1458
  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.
@@ -1419,7 +1462,7 @@ Counters are adjusted with `update_counters` (a single atomic SQL `COALESCE(col,
1419
1462
  **Notes**
1420
1463
  - The `belongs_to` must be declared **before** the macro (the reflection is validated at declaration). Polymorphic associations are not supported.
1421
1464
  - Don't also set native `counter_cache: true` on the same column — both would fire and double-count.
1422
- - 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.
1465
+ - Counters track the **persisted** record; writes that skip callbacks (`update_column(s)`, `update_all`, `delete`) are not tracked — run `recount_counter_caches!` to reconcile. Bare, it rewrites every parent (portable across adapters, but O(n) for conditional counters) — a maintenance operation, run it offline. With `parents:` it zeroes and re-tallies only those parents (O(their children)) in one transaction that locks those rows before tallying, so repairing one imported post is safe on the request path.
1423
1466
  - Reach for [`counter_culture`](https://github.com/magnusvk/counter_culture) when you need multi-level rollups, delta columns, or after-commit execution.
1424
1467
 
1425
1468
  ---
@@ -1720,7 +1763,7 @@ end
1720
1763
 
1721
1764
  ## 📦 Respondable
1722
1765
 
1723
- Standardized JSON envelopes for API controllers — two methods, zero state.
1766
+ Standardized JSON envelopes for API controllers — two intent-revealing render methods, plus an opt-in app-wide switch to RFC 9457 problem details.
1724
1767
 
1725
1768
  ```ruby
1726
1769
  class Api::ArticlesController < ApplicationController
@@ -1754,12 +1797,27 @@ end
1754
1797
  { "success": false, "error": { "message": "...", "code": "...", "details": [...] } }
1755
1798
  ```
1756
1799
 
1800
+ **RFC 9457 problem details** — switch the error format once and every 4xx the gem's concerns render
1801
+ through `render_error` (ErrorHandleable, Throttleable, Idempotentable, CursorPaginatable, Deprecatable,
1802
+ WebhookVerifiable, Authorizable) becomes an `application/problem+json` document; `render_success` is untouched:
1803
+
1804
+ ```ruby
1805
+ respondable_by error_format: :problem_details, problem_type_base: "https://api.example.com/problems"
1806
+ # 422 application/problem+json
1807
+ # { "type": "https://api.example.com/problems/record_invalid", "title": "Unprocessable Content", "status": 422,
1808
+ # "detail": "Validation failed", "instance": "/api/articles", "code": "record_invalid", "errors": ["Name can't be blank"] }
1809
+ ```
1810
+
1811
+ `type` is `problem_type_base/<code>` (or `about:blank` without a base or code), `title` the status reason phrase,
1812
+ `instance` the request path; `code` and `errors` ride along as extension members.
1813
+
1757
1814
  **API**
1758
1815
 
1759
1816
  | Method | Signature |
1760
1817
  |-------------------|--------------------------------------------------------------------------------------------|
1761
1818
  | `render_success` | `render_success(data: nil, status: :ok, meta: {})` |
1762
1819
  | `render_error` | `render_error(message:, status: :unprocessable_entity, code: nil, errors: nil)` |
1820
+ | `respondable_by` | `respondable_by(error_format: :envelope, problem_type_base: nil)` — class-level; `error_format:` is `:envelope` (default) or `:problem_details` |
1763
1821
 
1764
1822
  > `data:` is a keyword arg (not positional) on purpose — it sidesteps Ruby 3's behavior of treating hash literals as kwargs when a method declares any keyword params.
1765
1823
 
@@ -1856,18 +1914,20 @@ end
1856
1914
 
1857
1915
  ## 🔗 Includable
1858
1916
 
1859
- Whitelisted association sideloading + sparse fieldsets for JSON APIs — zero arbitrary `.includes` from user input.
1917
+ Whitelisted association sideloading + sparse fieldsets for JSON APIs — zero arbitrary `.includes` from user input, nested paths included.
1860
1918
 
1861
1919
  ```ruby
1862
1920
  class ArticlesController < ApplicationController
1863
1921
  include ConcernsOnRails::Controllers::Includable
1864
1922
 
1865
- includable :author, :comments,
1866
- fields: { articles: %i[id title published_at], authors: %i[id name] }
1923
+ includable :author, comments: :author, # flat + nested, like `includes` arguments
1924
+ fields: { articles: %i[id title published_at], authors: %i[id name] },
1925
+ default: :author, # loaded when the client sends no ?include at all
1926
+ strategy: :preload # :includes (default) | :preload | :eager_load
1867
1927
 
1868
1928
  def index
1869
1929
  render json: with_includes(Article.all),
1870
- include: requested_includes,
1930
+ include: requested_includes(as: :json),
1871
1931
  fields: requested_fields
1872
1932
  end
1873
1933
  end
@@ -1876,19 +1936,22 @@ end
1876
1936
  **URL params**
1877
1937
 
1878
1938
  ```
1879
- GET /articles?include=author,comments&fields[articles]=id,title&fields[authors]=id,name
1939
+ GET /articles?include=author,comments.author&fields[articles]=id,title&fields[authors]=id,name
1880
1940
  ```
1881
1941
 
1882
1942
  **API**
1883
1943
 
1884
- | Method | What it does |
1885
- |----------------------|--------------------------------------------------------------------------------------------|
1886
- | `with_includes(rel)` | Parses `params[:include]`, intersects with the allow-list, calls `relation.includes(...)` |
1887
- | `requested_includes` | Returns the sanitized `[:author, :comments]` array (pass to `render json:, include:`) |
1888
- | `requested_fields` | Returns `{ articles: [:id, :title] }` sanitized map (pass to your serializer) |
1944
+ | Method | What it does |
1945
+ |-------------------------------|--------------------------------------------------------------------------------------------------|
1946
+ | `with_includes(rel)` | Parses `params[:include]`, keeps only allow-listed paths, applies them with the configured strategy |
1947
+ | `requested_includes(as:)` | `:query` (default) `[:author, { comments: :author }]` for `includes`/`preload`; `:paths` → `["author", "comments.author"]` for JSON:API serializers; `:json` → `[:author, { comments: { include: :author } }]` for `as_json`/`render json:` |
1948
+ | `requested_include_paths` | The sanitized dotted paths in request order (what `as: :paths` returns) |
1949
+ | `requested_fields` | Returns `{ articles: [:id, :title] }` sanitized map (pass to your serializer) |
1889
1950
 
1890
1951
  **Notes**
1891
- - Non-whitelisted associations are **silently dropped** — no error, no arbitrary eager-loading.
1952
+ - A path is kept only if **every** segment follows the allow-list tree (`comments.author` needs `comments: :author`); anything else is **silently dropped** — no error, no arbitrary eager-loading.
1953
+ - `default:` applies only when `?include` is absent; `?include=` (blank) means "nothing" and is honoured. Defaults are validated against the allow-list at class load, stored frozen and `dup`ed per request.
1954
+ - **A typo in an option name becomes an association.** Inline nested Hashes arrive as `**nested`, so any keyword the macro does not name is registered as an allow-listed association instead of raising: `feilds:` silently leaves `includable_fields` empty — i.e. no sparse-fieldset allow-list at all.
1892
1955
  - Non-whitelisted tables in `params[:fields]` are dropped; non-whitelisted columns within an allowed table are dropped.
1893
1956
  - Pass `requested_fields` to your serializer (e.g. AMS / Blueprinter) — `Includable` itself does not alter the JSON output, only the query.
1894
1957
 
@@ -2010,12 +2073,15 @@ class Api::BaseController < ApplicationController
2010
2073
  throttle_by limit: 100, period: 1.minute # by IP (default)
2011
2074
  throttle_by limit: 5, period: 1.minute, only: :create,
2012
2075
  by: -> { current_user&.id || request.remote_ip }
2076
+ throttle_by limit: 1000, period: 1.hour, unless: :staff? # skip conditions
2013
2077
  end
2014
2078
  ```
2015
2079
 
2016
2080
  Fixed-window counter: the key embeds a floored time bucket (`epoch / period`) so each window starts clean and `X-RateLimit-Reset` is exact.
2017
2081
 
2018
- **Options**: `limit:` (positive integer), `period:` (a `Duration` or seconds), `by:` (discriminator lambda, default per-IP), `only:` / `except:` (mutually exclusive action scoping), `name:` (disambiguates the counter key).
2082
+ **Options**: `limit:` (positive integer), `period:` (a `Duration` or seconds), `by:` (discriminator lambda, default per-IP), `only:` / `except:` (mutually exclusive action scoping), `if:` / `unless:` (a Symbol naming a controller method or a callable, evaluated per request — staff accounts, internal IPs, feature flags; both must pass when both given), `name:` (disambiguates the counter key).
2083
+
2084
+ When several rules apply to one request the `X-RateLimit-*` headers describe the **tightest** one (fewest requests remaining, ties going to the rule that resets last), so a client sees the budget that runs out first. A throttled request instruments `rate_limited.concerns_on_rails` (payload: `rule`, `discriminator`, `count`, `limit`, `period`, `reset_at`, `retry_after`, `controller`, `action`) via the public `on_rate_limited(rule, result)` hook before the 429 is rendered — subscribe to alert on abusive clients, or override it (call `super` to keep the event). Note `discriminator` is the **raw** client IP (or user id) — personal data that `filter_parameters` does not reach, so hash, truncate or drop it in `on_rate_limited` if subscribers persist it.
2019
2085
 
2020
2086
  **Notes**
2021
2087
  - The store MUST support **atomic increment-with-expiry** (`Rails.cache` with `#increment`, or Redis) — a non-atomic store under-counts under concurrency.
@@ -2062,12 +2128,13 @@ class PaymentsController < ApplicationController
2062
2128
  end
2063
2129
  ```
2064
2130
 
2065
- Per-key lifecycle: claim atomically (`write unless_exist`, TTL `lock_ttl:`) → run action → cache 2xx–4xx responses for `ttl:`; 5xx and raised exceptions release the claim so the client can retry. Replays carry `X-Idempotency-Replayed: true`; duplicates in flight get 409 + `Retry-After`; reusing a key with a **different payload** gets 422 (`idempotency_key_reuse`, fingerprint overridable via `idempotency_fingerprint`).
2131
+ Per-key lifecycle: claim atomically (`write unless_exist`, TTL `lock_ttl:`) → run action → cache 2xx–4xx responses for `ttl:`; 5xx and raised exceptions release the claim so the client can retry. Replays carry `X-Idempotency-Replayed: true` **and the original's `Location` / `Content-Location` / `ETag` / `Last-Modified` / `Link` headers** (captured with the cached response — a replayed 201 still says where the resource lives; tune the allow-list with `headers:`, `[]` to capture none); duplicates in flight get 409 + `Retry-After`; reusing a key with a **different payload** gets 422 (`idempotency_key_reuse`, fingerprint overridable via `idempotency_fingerprint`).
2066
2132
 
2067
- **Options**: `*actions` (allow-list, required), `ttl:` (`24.hours`), `lock_ttl:` (`1.minute`), `header:` (`"Idempotency-Key"`), `required:` (`false`).
2133
+ **Options**: `*actions` (allow-list, required), `ttl:` (`24.hours`), `lock_ttl:` (`1.minute`), `header:` (`"Idempotency-Key"`), `required:` (`false`), `headers:` (response headers replayed with the cached response; default `%w[Location Content-Location ETag Last-Modified Link]` — an allow-list on purpose: `Set-Cookie`, `Date`, request ids and rate-limit headers describe the original exchange and are never replayed).
2068
2134
 
2069
2135
  **Notes**
2070
2136
  - Cache keys are scoped per `controller#action` and the client key is SHA256-hashed, so the same key on different endpoints never collides.
2137
+ - The scope carries **no principal**: with client-chosen keys, two users sending the same key and payload to one endpoint share a record (the second is served the first's response, `Location` included). Override `idempotency_scope` — `def idempotency_scope = "#{super}:#{current_user&.id}"`.
2071
2138
  - There is **no in-process default store** on purpose: the first keyed request raises `ArgumentError` until you set `idempotency_store` (or the gem-wide fallback `ConcernsOnRails.setup { |c| c.cache_store = -> { Rails.cache } }`).
2072
2139
  - When `Respondable` is included, the 400/409/422 bodies delegate to `render_error`.
2073
2140
  - Declare halting filters (authentication, `Throttleable`) **before** including this concern — a 401/403 rendered by an inner filter would be cached and replayed for the full TTL. Responses rendered by `rescue_from` handlers are never cached.
@@ -2300,9 +2367,9 @@ Point your agent at `llms.txt` for an overview, or paste a single concern's `.md
2300
2367
 
2301
2368
  ```sh
2302
2369
  bundle install # install dev dependencies
2303
- bundle exec rspec # run the test suite (1,522 examples)
2370
+ bundle exec rspec # run the test suite (1,624 examples)
2304
2371
  gem build concerns_on_rails.gemspec # build the gem
2305
- gem install ./concerns_on_rails-1.28.5.gem # install locally
2372
+ gem install ./concerns_on_rails-1.28.6.gem # install locally
2306
2373
 
2307
2374
  # Preview the docs site locally (GitHub Pages serves docs/ as-is):
2308
2375
  cd docs && python3 -m http.server 8000 # → http://localhost:8000
@@ -14,8 +14,32 @@ module ConcernsOnRails
14
14
  # #write(expires_in:, unless_exist:) / #delete for idempotency. There is
15
15
  # still no in-process default on purpose — a non-atomic store silently
16
16
  # under-counts, so the host must opt in explicitly (just once, here).
17
+ #
18
+ # `audit_actor` is the fallback actor for Models::Auditable: a zero-arg
19
+ # callable (a Proc is instance_exec'd on the record at save time, any other
20
+ # callable is #call'd), whose value is stamped as "by" on every audit entry
21
+ # of every model that passes no `actor:` of its own (`auditable_by ...,
22
+ # actor: -> { ... }` still wins; an explicit `actor: nil`/`actor: false`
23
+ # opts a model out). Typically `-> { Current.user&.id }`; nil or false
24
+ # means no gem-wide actor.
17
25
  class Configuration
18
26
  attr_accessor :cache_store
27
+ attr_reader :audit_actor
28
+
29
+ # false is accepted as a synonym for nil ("no gem-wide actor"). A lambda
30
+ # is arity-strict, so one that demands arguments is rejected here rather
31
+ # than raising from inside before_save on every audited save.
32
+ def audit_actor=(value)
33
+ unless value.nil? || value == false || value.respond_to?(:call)
34
+ raise ArgumentError, "ConcernsOnRails.config.audit_actor must be callable (respond to #call), nil or false"
35
+ end
36
+ unless audit_actor_callable_without_args?(value)
37
+ raise ArgumentError,
38
+ "ConcernsOnRails.config.audit_actor lambda must take no arguments (it is instance_exec'd on the record)"
39
+ end
40
+
41
+ @audit_actor = value
42
+ end
19
43
 
20
44
  # The fallback store with any callable resolved (per lookup, so a Proc
21
45
  # reading Rails.cache follows a swapped-out cache in tests). nil when the
@@ -23,5 +47,16 @@ module ConcernsOnRails
23
47
  def resolved_cache_store
24
48
  cache_store.respond_to?(:call) ? cache_store.call : cache_store
25
49
  end
50
+
51
+ private
52
+
53
+ # Only lambdas enforce arity: 0 (no parameters) and -1 (*args or optional
54
+ # parameters only) can still be called with none. Non-lambda Procs and
55
+ # other callables are left alone.
56
+ def audit_actor_callable_without_args?(value)
57
+ return true unless value.is_a?(Proc) && value.lambda?
58
+
59
+ value.arity.zero? || value.arity == -1
60
+ end
26
61
  end
27
62
  end