concerns_on_rails 1.28.1 → 1.28.3

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: 00337457f7ea35fd2e02bb74bcb4031208109536946b448706104a9e031bd9ea
4
- data.tar.gz: 3bfc8749303d212e51d98d8dd7fa4323888e3d984444c768695d5c290df311a3
3
+ metadata.gz: d3f06aad189272886038354ad98dfd58a84642d64e1313c4aac5d88e7d1ec90a
4
+ data.tar.gz: b38acca4cb5cd6a48496d0d4909da90782c14b6b71748af88b72df482a02ef62
5
5
  SHA512:
6
- metadata.gz: 50ea50c59f6b15978fe3f1cc86f03266998249bd4b17dc84b36ff6a717e9a57d0efba37f2e5648c58009761aeb2198f14368b2c20715c0bc40c246c1e7b5ee25
7
- data.tar.gz: f6b8c7784e26ac3f5c8f14217e07cbe6d846ecab216fad6c61fb192bf06a18aa4484d80efb32fd82d625c683d917d9fb8c9b88724adcf2a11878fb0ea6a1f7e9
6
+ metadata.gz: 98d77a84da148195bdd1c013054b12bd3b8c4f6ce2bf5b7ae8ee7e91c0a24b4d5d45e6540c139456e1d51b91afbfaed5cf68d9faf9859399cb4ec2b76ccdd90b
7
+ data.tar.gz: 6852cb66a64150139c9625d765cd3f724881a6b35a9e5920140713c21b69f148c956353d587765f3ad1eca6018b38de70b98aa8aa9c40006ad0a6919242607c8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,115 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.28.3 (2026-09-16)
4
+
5
+ Three merged PRs from the September loop (#41, #43, #52), shipped as a patch at
6
+ the maintainer's request: a SoftDeletable bug fix (`restore_all` /
7
+ `really_destroy_all` dropped the caller's own predicate on the soft-delete
8
+ column), a ColumnGuard change (every missing column reported in one error with
9
+ one migration command) and an additive SoftDeletable `cascade:` option for
10
+ has_many / has_one dependents. No new migrations or runtime dependencies.
11
+ 1396 examples, 0 failures.
12
+
13
+ ### Fixed
14
+ - **Models::SoftDeletable**: `restore_all` and `really_destroy_all` now honour a
15
+ caller's predicate on the soft-delete column. Both used to `unscope` the
16
+ column outright to peel off the default scope's `deleted_at IS NULL`, which
17
+ also dropped `deleted_within(1.hour)` / `where(deleted_at: range)` /
18
+ `only_deleted` — so `User.deleted_within(1.hour).restore_all` restored the
19
+ whole trash can and `only_deleted.really_destroy_all` widened to the whole
20
+ relation. Only the default scope's own predicate is peeled now; predicates on
21
+ other columns (a host model's own `default_scope` included) are untouched. The
22
+ scopes themselves still unscope the column: chain `soft_deleted.where(...)`,
23
+ not `where(...).soft_deleted`. (#41)
24
+ - **Models::Anonymizable**: the stamp column's migration hint now carries its
25
+ type (`anonymized_at:datetime`). (#43)
26
+
27
+ ### Changed
28
+ - **Support::ColumnGuard**: a macro that finds several missing columns now
29
+ reports them all in one `ArgumentError` — `'street', 'city' and 'zip' do not
30
+ exist …` — with a single combined migration command
31
+ (`bin/rails generate migration AddAddressableColumnsToUsers street:string
32
+ city:string zip:string`) instead of failing boot once per column. Single-
33
+ column wording and generator name are unchanged. (#43)
34
+
35
+ ### Added
36
+ - **Models::SoftDeletable**: `soft_deletable_by … cascade: %i[comments cover]`
37
+ soft-deletes has_many / has_one dependents with the record (same
38
+ transaction, same timestamp, through their own `soft_delete!` so hooks and
39
+ nested cascades run) and restores exactly those on `restore!` — a dependent
40
+ deleted independently earlier stays deleted. New `soft_delete!(at:)` keyword.
41
+ With a cascade configured, `soft_delete_all` / `restore_all` take the
42
+ per-record path. `belongs_to`, HABTM and `:through` are rejected at class
43
+ load; the target model must include SoftDeletable (checked at class load when
44
+ it already resolves, otherwise on the first cascade). (#52)
45
+
46
+ ### Notes
47
+ `cascade:` is off by default — models without it behave exactly as before.
48
+ `restore!` matches cascaded dependents by the parent's exact `deleted_at`, so
49
+ give the columns `precision: 6` (the Rails 7 default) if two parents may be
50
+ deleted within one second. With a cascade configured the single-`UPDATE` fast
51
+ paths of `soft_delete_all` / `restore_all` are disabled (a bulk `UPDATE` cannot
52
+ follow associations). Anything matching `/does not exist/` on a one-column
53
+ ColumnGuard failure still matches — only the several-columns wording and
54
+ generator name changed. The README's "use instead" table no longer lists
55
+ association-cascade soft delete as a reason to reach for paranoia / discard.
56
+
57
+ ## 1.28.2 (2026-09-11)
58
+
59
+ Six merged enhancement PRs from the September loop (#42, #84, #59 via #90, #80,
60
+ #81, #82), all additive and column-free: ErrorHandleable rescues 14 exceptions
61
+ instead of 3 and instruments every handled error, CursorPaginatable cursors can
62
+ be HMAC-signed, Sortable applies a drag-and-drop order in one UPDATE, Sluggable
63
+ gains slug candidates / word-boundary truncation / forced regeneration, and
64
+ Sequenceable can defer numbering until an invoice is finalized. No new
65
+ migrations or runtime dependencies. 1360 examples, 0 failures.
66
+
67
+ ### Added
68
+ - **Controllers::ErrorHandleable**: the exception map grows from 3 to 14 —
69
+ `ActiveModel::ValidationError`, `RecordNotSaved`, `RecordNotDestroyed` (422),
70
+ `StaleObjectError`, `RecordNotUnique`, `InvalidForeignKey` (409),
71
+ `UnpermittedParameters`, `BadRequest`, `ParseError` (400),
72
+ `InvalidAuthenticityToken` (422) and `UnknownFormat` (406). Statuses follow
73
+ Rails' `rescue_responses`; database/parser-level errors render generic
74
+ messages so SQL, schema names and request input never leak. New
75
+ `handle_errors only:/except:` macro trims the map without touching a host
76
+ `rescue_from`; `error_handleable_keys` lists the active keys. (#42)
77
+ - **Controllers::ErrorHandleable**: every handled error instruments
78
+ `handled_error.concerns_on_rails` (controller, action, code, status, message,
79
+ exception) via the public `on_handled_error(key, error, status:, message:)`
80
+ override point — report some codes to the error tracker, let the rest stay
81
+ quiet. (#84)
82
+ - **Controllers::CursorPaginatable**: `cursor_paginate_by … signed: true` (or a
83
+ String key / callable) appends a URL-safe HMAC-SHA256 to every cursor and
84
+ rejects tampered, mis-signed or unsigned tokens with the usual 400, so
85
+ clients can no longer hand-craft page positions. Unsigned stays the default.
86
+ (#59, merged via #90)
87
+ - **Sortable (model)**: `Model.reposition!(ids, missing: :append | :raise)`
88
+ applies a drag-and-drop id order as positions in one `UPDATE … CASE` inside
89
+ the current relation (unlisted rows appended or raised, foreign/duplicate ids
90
+ rejected, String ids cast, `:desc` lists inverted). Returns the count. (#80)
91
+ - **Sluggable**: `candidates:` (friendly_id slug candidates tried in order
92
+ before the uuid fallback; regeneration still keyed to the primary field),
93
+ `max_length:` (word-boundary truncation, suffix appended after) and
94
+ `regenerate_slug!` (rebuild from the current source even over a
95
+ hand-assigned slug). (#81)
96
+ - **Sequenceable**: `assign: :manual` defers numbering until `assign_<field>!`
97
+ (idempotent — `true` when assigned, `false` when already numbered; `save!`s
98
+ persisted records, leaves new ones for the caller's save), plus
99
+ `<field>_assigned?` and a `pending_<field>` scope. Numbering then follows
100
+ finalization order; `reset:` periods still come from `created_at`. (#82)
101
+
102
+ ### Notes
103
+ Apps that include ErrorHandleable now get 11 more exceptions rescued into JSON
104
+ 4xx responses. One that relied on, say, `StaleObjectError` propagating to its
105
+ error tracker should add `handle_errors except: :stale_object` (or `only:` the
106
+ original trio) — or override `on_handled_error` to report the codes it cares
107
+ about. Exceptions are registered by name, so a class a given Rails version
108
+ lacks is simply never matched. Turning on `signed:` invalidates in-flight
109
+ unsigned cursors (there is no mixed mode by design), so clients restart from
110
+ the first page. `reposition!` writes positions with `update_all` and therefore
111
+ bypasses acts_as_list's per-row callbacks by design.
112
+
3
113
  ## 1.28.1 (2026-09-07)
4
114
 
5
115
  One additive Paginatable option (#89): `pagination_meta` can now publish the
data/README.md CHANGED
@@ -146,9 +146,9 @@ across all 43 concerns — press <kbd>/</kbd> and type.
146
146
  - **Twenty-six model concerns + sixteen controller concerns**, all production-ready
147
147
  - **One include, one macro** — no boilerplate, no glue code
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
- - **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early — with a ready-to-paste `rails generate migration` hint when it doesn't
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,314 RSpec examples** run against a real database on every CI build
151
+ - **Tested like an app, not a snippet** — **1,396 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
  ---
@@ -275,6 +275,10 @@ sluggable_by :title, reserved_words: %w[new edit admin]
275
275
 
276
276
  # Let Model.find accept a slug directly (not just the id)
277
277
  sluggable_by :title, finders: true
278
+ sluggable_by :title, candidates: [:title, %i[title city]] # try "title", then "title-city", then friendly_id's uuid suffix on the first candidate
279
+ sluggable_by :title, max_length: 60 # truncate at a word boundary (uniqueness suffix added after)
280
+
281
+ page.regenerate_slug! # rebuild from the current source — even over a hand-assigned slug
278
282
  Post.find("hello-world") # resolves by slug
279
283
  ```
280
284
 
@@ -282,6 +286,9 @@ Post.find("hello-world") # resolves by slug
282
286
  - Schema must have a `slug` column (string).
283
287
  - `history: true` requires a `friendly_id_slugs` table — generate with `rails generate friendly_id` or add a manual migration.
284
288
  - `scope: :col` requires `col` to exist in the same table.
289
+ - `candidates:` takes friendly_id's shapes — a Symbol/String method, a Proc, or an Array of those joined with `-` — tried in order until one is free (all taken → the first candidate plus a uuid); the slug still regenerates only when the **primary** field changes (a candidate-only change doesn't churn the URL), and a NULL slug backfills through the candidates.
290
+ - `max_length:` truncates each candidate at the last `-` inside the limit (a single long word is hard-cut); friendly_id's conflict suffix is appended afterwards, so a colliding slug may exceed the limit — unlike friendly_id's own `slug_limit`, which squeezes the uuid inside it.
291
+ - `regenerate_slug!` is the escape hatch for the explicit-slug rule: it forces regeneration and saves (`save!`), keeping uniqueness handling.
285
292
  - Falls back to `to_s` if the configured source field doesn't respond.
286
293
  - Uses friendly_id's `:slugged` (+ optionally `:history`, `:scoped`) strategies under the hood.
287
294
 
@@ -301,6 +308,10 @@ end
301
308
  Task.create!(name: "A")
302
309
  Task.create!(name: "B")
303
310
  Task.last.move_higher
311
+
312
+ # Save a drag-and-drop order in ONE UPDATE (CASE id WHEN …): the ids' order becomes their positions
313
+ Task.reposition!(params[:ids]) # => 12 — rows not listed are pushed after, in their current order
314
+ Task.where(list_id: 1).reposition!(ids, missing: :raise) # scoped; a partial list is an error
304
315
  ```
305
316
 
306
317
  **Configuration**
@@ -314,8 +325,8 @@ sortable_by :position, add_new_at: :top # new rows insert at the top (a
314
325
  ```
315
326
 
316
327
  **Notes**
317
- - The configured field must exist as a column.
318
- - Direction values other than `:asc` / `:desc` silently fall back to `:asc`.
328
+ - The configured field must exist as a column; a direction other than `:asc` / `:desc` raises at declaration.
329
+ - `reposition!` runs inside the current relation (`Task.where(list_id: 1)` — the same set acts_as_list's `scope:` would use), rejects ids outside it and duplicates before writing anything, coerces String ids from params, and on a descending list gives the first id the highest value. It bypasses acts_as_list callbacks by design (one `update_all`, no per-row shifting).
319
330
 
320
331
  ---
321
332
 
@@ -456,13 +467,21 @@ User.soft_delete_all # soft-deletes all matching records; returns the count
456
467
  User.destroy_all # alias of soft_delete_all (kept for backwards compatibility; returns a count, not records)
457
468
  User.really_destroy_all # hard-deletes the records matching the CURRENT relation (soft-deleted included)
458
469
  User.restore_all # restores the matching soft-deleted records; returns the count
470
+
471
+ User.deleted_within(1.hour).restore_all # undo a bulk delete — only the last hour's trash
472
+ User.deleted_within(30.days).really_destroy_all # purge recent trash; older rows untouched
473
+ User.only_deleted.really_destroy_all # empty the trash can, nothing else
459
474
  ```
460
475
 
461
476
  A record that fails to transition raises `ActiveRecord::RecordNotSaved` and rolls the whole
462
477
  batch back. With `touch: false` and no overridden hooks, `soft_delete_all` / `restore_all`
463
- collapse to a single `UPDATE`. Note that `really_destroy_all` peels the soft-delete
464
- predicate off the relation, so `only_deleted.really_destroy_all` widens to the whole
465
- relationpurge trash with `User.soft_deleted.delete_all` instead.
478
+ collapse to a single `UPDATE`. Both `restore_all` and `really_destroy_all` peel off **only the
479
+ default scope's own** `deleted_at IS NULL`: a predicate *you* put on the column — `deleted_within`,
480
+ `where(deleted_at: range)`, `only_deleted` survives, as does any other default scope the model
481
+ declares. (Previously they unscoped the column outright, so `deleted_within(1.hour).restore_all`
482
+ restored the whole trash can and `only_deleted.really_destroy_all` widened to the whole relation.)
483
+ The *scopes* still unscope the column, so chain them first: `soft_deleted.where(...)`, not
484
+ `where(...).soft_deleted`.
466
485
 
467
486
  **Scope-name collisions**
468
487
 
@@ -479,6 +498,30 @@ Expirable) without a collision. `prefix: true` uses the configured field name. W
479
498
  passed, scope names, the default scope, and the emitted SQL are unchanged. See the
480
499
  Publishable section above for how `prefix:`/`suffix:` differ across the gem.
481
500
 
501
+ **Cascading to dependents**
502
+
503
+ ```ruby
504
+ class Post < ApplicationRecord
505
+ include ConcernsOnRails::SoftDeletable
506
+ has_many :comments
507
+ has_one :cover
508
+ soft_deletable_by :deleted_at, cascade: %i[comments cover] # Comment and Cover include SoftDeletable too
509
+ end
510
+
511
+ post.soft_delete! # comments + cover soft-deleted in the same transaction, with the post's exact timestamp
512
+ post.restore! # brings back the comments/cover the cascade deleted — NOT a comment someone trashed last week
513
+ post.soft_delete!(at: 1.day.ago) # new at: keyword — backdate, or hand a timestamp down a cascade
514
+ ```
515
+
516
+ Dependents go through their own `soft_delete!` / `restore!` (hooks and nested cascades run). A dependent
517
+ that fails — whether it raises or just fails validation — aborts the cascade with
518
+ `ActiveRecord::RecordNotSaved` and rolls the parent back with it, so you never end up with a deleted
519
+ parent and a live child. Declare the cascaded associations **above** `soft_deletable_by`; the macro
520
+ resolves them at class load. Restore matches on the parent's timestamp, so independently
521
+ deleted dependents keep their own. `cascade:` accepts `has_many` / `has_one` (no `belongs_to`, HABTM or
522
+ `:through`) whose models include SoftDeletable; with a cascade configured `soft_delete_all` / `restore_all`
523
+ take the per-record path (a bulk `UPDATE` cannot follow associations).
524
+
482
525
  **Lifecycle hooks** — override these methods on the model:
483
526
 
484
527
  ```ruby
@@ -854,6 +897,7 @@ Invoice.next_sequence(account_id: 1) # => 4 (peek the next value, without cre
854
897
  | `scope:` | `nil` | Column (or array of columns) the counter is scoped to — e.g. one sequence per `account_id`. |
855
898
  | `reset:` | `:never` | `:never` / `:year` / `:month` / `:day` — restart numbering each period (needs `created_at`). |
856
899
  | `template:` | `nil` | `->(seq, record) { ... }` full custom formatter; overrides `prefix` / `padding` / period. |
900
+ | `assign:` | `:create` | `:create` numbers every record in `before_create`; `:manual` leaves the column NULL until `assign_<field>!` is called — for invoices that get their number when finalized, not when drafted. |
857
901
 
858
902
  **Default format**
859
903
 
@@ -870,10 +914,14 @@ Invoice.next_sequence(account_id: 1) # => 4 (peek the next value, without cre
870
914
  |-----------------------------------|---------------------------------------------------------------------------------------|
871
915
  | `formatted_<field>` | The formatted string — the persisted `into:` value when set, otherwise computed. |
872
916
  | `Model.next_<field>(scope_attrs)` | Peek the next integer for a scope without creating a record. |
917
+ | `assign_<field>!` | Number the record now (`assign: :manual`, or any row still blank): next value + `into:` string, `save!`d when persisted, left for your save when new. `true` when assigned, `false` when already numbered. |
918
+ | `<field>_assigned?` | Whether the record has its number. |
919
+ | `Model.pending_<field>` | Scope: rows still awaiting a number (`WHERE <field> IS NULL`). |
873
920
 
874
921
  **Notes**
875
922
  - The next value is `MAX(<field>) + 1` within the scope (and period), so numbering is dense and ordered — not random.
876
923
  - Caller-supplied values are respected: `Invoice.create!(sequence: 100)` is not overwritten (and its `into:` string is still formatted from `100`).
924
+ - With `assign: :manual`, numbering follows **assignment** order (the first invoice finalized is #1, whenever it was drafted); with `reset:` the period is still taken from the row's `created_at`, exactly as on create.
877
925
  - Generation reads `MAX` then inserts, so two concurrent inserts can race. It's **best-effort** — add a **scoped unique index** on `<field>` (and on `into:`) for a real guarantee, the same way you would for any `MAX`-based numbering.
878
926
  - `reset:` requires a `created_at` column; the period is taken from each row's creation time.
879
927
  - For fixed-width display (`00042`), make the `into:` column a **string** — integer columns drop leading zeros.
@@ -1565,7 +1613,7 @@ end
1565
1613
 
1566
1614
  **Notes**
1567
1615
  - The primary key is always appended as a tiebreaker, so duplicate values never skip or repeat rows; ordering columns are chosen **in code** (never from params) and should be `NOT NULL` (a NULL boundary value raises rather than silently dropping rows).
1568
- - Cursors are opaque, table/order-pinned tokens — a malformed, cross-endpoint, or stale-config cursor renders a 400 (`invalid_cursor`; override `render_invalid_cursor` to customize, delegates to Respondable's `render_error` when present). They are **not signed**: a client can mint different boundary values, but values are cast through the model's attribute types and bound by Arel (no injection) and the relation's own scoping still applies — treat a cursor as a page position, never an authorization boundary.
1616
+ - Cursors are opaque, table/order-pinned tokens — a malformed, cross-endpoint, or stale-config cursor renders a 400 (`invalid_cursor`; override `render_invalid_cursor` to customize, delegates to Respondable's `render_error` when present). Unsigned by default: a client can mint different boundary values, but values are cast through the model's attribute types and bound by Arel (no injection) and the relation's own scoping still applies — treat a cursor as a page position, never an authorization boundary. **`signed: true`** (or a String key, or a callable for rotating keys) appends a URL-safe HMAC-SHA256 to every cursor and rejects tampered or unsigned tokens with the same 400, so clients can no longer hand-craft positions at all; `true` uses `Rails.application.secret_key_base`. Turning it on invalidates in-flight cursors (clients restart from page one).
1569
1617
  - `cursor_paginated` uses `reorder` (replaces any `default_scope` ORDER BY) and returns a loaded Array. Don't wrap it with the controller Sortable's `sorted` — pass `order:` per call instead.
1570
1618
  - Forward-only by default — `bidirectional: true` (macro or per call) adds prev cursors and `X-Has-Prev`/`X-Prev-Cursor`; direction is pinned in the token, so prev tokens replayed on forward-only endpoints 400 and old direction-less tokens stay valid. `order_presets: { newest: {...}, top: {...} }` (+ `default_preset:`, `order_param:`) lets clients pick a **named** ordering from an allow-list. `predicate: :auto` upgrades the keyset WHERE to a row-value tuple `(a, b, id) > (x, y, z)` on PostgreSQL/MySQL/SQLite when directions are uniform — composite-index friendly — falling back to the portable OR-expansion (`:row`/`:or` force a strategy).
1571
1619
  - Use Paginatable when you need page numbers and totals.
@@ -1681,22 +1729,42 @@ end
1681
1729
 
1682
1730
  ## 🛟 ErrorHandleable
1683
1731
 
1684
- Install `rescue_from` handlers for the three most common controller exceptions and render them as the same JSON envelope used by Respondable.
1732
+ Install `rescue_from` handlers for the controller exceptions a JSON API meets in practice and render them as the same JSON envelope used by Respondable.
1685
1733
 
1686
1734
  ```ruby
1687
1735
  class Api::BaseController < ApplicationController
1688
1736
  include ConcernsOnRails::Controllers::Respondable # recommended
1689
1737
  include ConcernsOnRails::Controllers::ErrorHandleable
1738
+
1739
+ handle_errors except: :stale_object # optional — let some propagate
1690
1740
  end
1691
1741
  ```
1692
1742
 
1693
1743
  **Handled exceptions**
1694
1744
 
1695
- | Exception | Status | `code` |
1696
- |----------------------------------------|--------|-----------------------|
1697
- | `ActiveRecord::RecordNotFound` | 404 | `"not_found"` |
1698
- | `ActionController::ParameterMissing` | 400 | `"parameter_missing"` |
1699
- | `ActiveRecord::RecordInvalid` | 422 | `"record_invalid"` |
1745
+ | Key (= `code`) | Exception | Status | `details` |
1746
+ |------------------------------|------------------------------------------------|--------|------------------------------|
1747
+ | `not_found` | `ActiveRecord::RecordNotFound` | 404 | |
1748
+ | `parameter_missing` | `ActionController::ParameterMissing` | 400 | |
1749
+ | `record_invalid` | `ActiveRecord::RecordInvalid` | 422 | `errors.full_messages` |
1750
+ | `validation_error` | `ActiveModel::ValidationError` | 422 | `model.errors.full_messages` |
1751
+ | `record_not_saved` | `ActiveRecord::RecordNotSaved` | 422 | record errors, if any |
1752
+ | `record_not_destroyed` | `ActiveRecord::RecordNotDestroyed` | 422 | record errors, if any |
1753
+ | `stale_object` | `ActiveRecord::StaleObjectError` | 409 | — |
1754
+ | `record_not_unique` | `ActiveRecord::RecordNotUnique` | 409 | — |
1755
+ | `foreign_key_violation` | `ActiveRecord::InvalidForeignKey` | 409 | — |
1756
+ | `unpermitted_parameters` | `ActionController::UnpermittedParameters` | 400 | the parameter names |
1757
+ | `invalid_authenticity_token` | `ActionController::InvalidAuthenticityToken` | 422 | — |
1758
+ | `bad_request` | `ActionController::BadRequest` | 400 | — |
1759
+ | `parse_error` | `ActionDispatch::Http::Parameters::ParseError` | 400 | — |
1760
+ | `unknown_format` | `ActionController::UnknownFormat` | 406 | — |
1761
+
1762
+ Statuses follow Rails' own `rescue_responses` wherever Rails has an opinion; the two database-constraint
1763
+ races Rails leaves as 500s (`RecordNotUnique`, `InvalidForeignKey`) get the REST-conventional 409. Messages
1764
+ for database- and parser-level errors are deliberately generic (`"Resource already exists"`,
1765
+ `"Malformed request body"`, …): the raw messages carry SQL fragments, table/column names, model class
1766
+ names or the offending input, none of which belongs in an API response. `details` is present only when
1767
+ there is something to list.
1700
1768
 
1701
1769
  Response shape (matches `Respondable#render_error`):
1702
1770
 
@@ -1718,8 +1786,32 @@ class Api::BaseController < ApplicationController
1718
1786
  end
1719
1787
  ```
1720
1788
 
1789
+ **Trimming the map**
1790
+
1791
+ ```ruby
1792
+ handle_errors except: :stale_object # let optimistic-lock conflicts reach the error tracker
1793
+ handle_errors except: %i[record_not_unique foreign_key_violation] # calls accumulate
1794
+ handle_errors only: %i[not_found parameter_missing record_invalid] # just the original trio
1795
+ ```
1796
+
1797
+ `handle_errors` removes only the concern's own registrations (matched on exception *and* handler), so a
1798
+ `rescue_from` you declared yourself for the same exception is untouched, and it never re-adds — your later
1799
+ declarations keep precedence. Unknown keys raise `ArgumentError` listing the valid ones;
1800
+ `error_handleable_keys` returns the keys still active on a controller.
1801
+
1802
+ **Reporting** — every handled error instruments `handled_error.concerns_on_rails` (`controller`, `action`, `code`, `status`, `message`, `exception`, `exception_class`) via the public `on_handled_error(key, error, status:, message:)` override point, so the 409s reach your error tracker while the 404s stay quiet:
1803
+
1804
+ ```ruby
1805
+ def on_handled_error(key, error, **)
1806
+ Sentry.capture_exception(error) if %i[record_not_unique foreign_key_violation stale_object].include?(key)
1807
+ super # keep the event
1808
+ end
1809
+ ```
1810
+
1721
1811
  **Notes**
1722
1812
  - When `Respondable` is also included, the handlers delegate to `render_error` so the envelope shape stays in one place. Otherwise they render the same envelope inline.
1813
+ - Exceptions are registered by name (string), so a class your Rails version lacks is simply never matched.
1814
+ - `ActionController::UnpermittedParameters` is only raised with `config.action_controller.action_on_unpermitted_parameters = :raise`.
1723
1815
  - `RecordInvalid.details` are populated from `error.record.errors.full_messages`.
1724
1816
 
1725
1817
  ---
@@ -2123,7 +2215,7 @@ Both forms reference the same module, so you can freely mix them.
2123
2215
  | Need | Use instead |
2124
2216
  |------|-------------|
2125
2217
  | Complex state machines (callbacks, transition logging) | [`aasm`](https://github.com/aasm/aasm) |
2126
- | Association-cascade soft delete / sentinel-aware unique indexes | [`paranoia`](https://github.com/rubysherpas/paranoia) or [`discard`](https://github.com/jhawthorn/discard) |
2218
+ | Sentinel-aware unique indexes on soft-deleted rows (`deleted_at` in the index) | [`paranoia`](https://github.com/rubysherpas/paranoia) or [`discard`](https://github.com/jhawthorn/discard) |
2127
2219
  | Tagging with contexts, ownership, or tag clouds | [`acts-as-taggable-on`](https://github.com/mbleigh/acts-as-taggable-on) |
2128
2220
  | Full-text search with ranking / stemming | [`pg_search`](https://github.com/Casecommons/pg_search) / Elasticsearch |
2129
2221
  | Versioned audit trails with undo/reify, who-dunnit queries, or association tracking | [`paper_trail`](https://github.com/paper-trail-gem/paper_trail) / [`audited`](https://github.com/collectiveidea/audited) |
@@ -2157,9 +2249,9 @@ Point your agent at `llms.txt` for an overview, or paste a single concern's `.md
2157
2249
 
2158
2250
  ```sh
2159
2251
  bundle install # install dev dependencies
2160
- bundle exec rspec # run the test suite (1,314 examples)
2252
+ bundle exec rspec # run the test suite (1,396 examples)
2161
2253
  gem build concerns_on_rails.gemspec # build the gem
2162
- gem install ./concerns_on_rails-1.28.1.gem # install locally
2254
+ gem install ./concerns_on_rails-1.28.3.gem # install locally
2163
2255
 
2164
2256
  # Preview the docs site locally (GitHub Pages serves docs/ as-is):
2165
2257
  cd docs && python3 -m http.server 8000 # → http://localhost:8000
@@ -3,6 +3,8 @@ require "concerns_on_rails/support/error_envelope"
3
3
  require "concerns_on_rails/support/scalar_param"
4
4
  require "json"
5
5
  require "concerns_on_rails/support/link_header"
6
+ require "openssl"
7
+ require "active_support/security_utils"
6
8
  require "time" # Time#iso8601(fraction_digits) lives in the stdlib time library
7
9
 
8
10
  module ConcernsOnRails
@@ -143,6 +145,9 @@ module ConcernsOnRails
143
145
  class_attribute :cursor_paginatable_bidirectional, default: false
144
146
  class_attribute :cursor_paginatable_predicate, default: :auto
145
147
  class_attribute :cursor_paginatable_link_header, default: true
148
+ # false (unsigned), true (Rails.application.secret_key_base), a String
149
+ # key, or a callable returning the key — see cursor_paginate_by.
150
+ class_attribute :cursor_paginatable_signed, default: false
146
151
 
147
152
  # Real controllers (anything with ActiveSupport::Rescuable) get the 400
148
153
  # handlers automatically; bare objects let the errors propagate.
@@ -163,7 +168,7 @@ module ConcernsOnRails
163
168
  # max_per_page: 0 (or negative) disables the per_page cap.
164
169
  def cursor_paginate_by(order: nil, order_presets: nil, default_preset: nil, order_param: :order,
165
170
  per_page: DEFAULT_PER_PAGE, max_per_page: DEFAULT_MAX_PER_PAGE,
166
- bidirectional: false, predicate: :auto, link_header: true)
171
+ bidirectional: false, predicate: :auto, link_header: true, signed: false)
167
172
  self.cursor_paginatable_order = order && CursorPaginatable.normalize_order!(order)
168
173
  self.cursor_paginatable_order_presets = order_presets && CursorPaginatable.normalize_presets!(order_presets)
169
174
  self.cursor_paginatable_default_preset =
@@ -174,9 +179,19 @@ module ConcernsOnRails
174
179
  self.cursor_paginatable_bidirectional = bidirectional ? true : false
175
180
  self.cursor_paginatable_predicate = CursorPaginatable.validate_predicate!(predicate)
176
181
  self.cursor_paginatable_link_header = link_header ? true : false
182
+ self.cursor_paginatable_signed = CursorPaginatable.validate_signed!(signed)
177
183
  end
178
184
  end
179
185
 
186
+ # `signed:` — false, true (the app's secret_key_base), a non-blank String,
187
+ # or a callable (resolved per request, so keys can rotate).
188
+ def self.validate_signed!(signed)
189
+ return signed if [true, false].include?(signed) || signed.respond_to?(:call)
190
+ return signed if signed.is_a?(String) && !signed.strip.empty?
191
+
192
+ raise ArgumentError, "#{name}: signed: must be true, false, a String or a callable"
193
+ end
194
+
180
195
  # Run the keyset query (limit + 1 to detect has_more), set the standard
181
196
  # response headers, and return the page as a loaded Array (laziness is
182
197
  # impossible here: has_more detection materializes limit + 1 rows).
@@ -339,7 +354,55 @@ module ConcernsOnRails
339
354
  "d" => direction,
340
355
  "v" => pairs.map { |col, _dir| serialize_cursor_value(cursor_boundary_value!(record, col)) }
341
356
  }
342
- cursor_base64_encode(JSON.generate(payload))
357
+ token = cursor_base64_encode(JSON.generate(payload))
358
+ cursor_signing_key ? "#{token}.#{cursor_signature(token)}" : token
359
+ end
360
+
361
+ # Signed cursors are `<url-safe base64 payload>.<hex HMAC-SHA256>` — the
362
+ # dot never appears in the payload alphabet, so the split is unambiguous
363
+ # and the whole token stays URL-safe. Verification is constant-time and
364
+ # happens BEFORE the payload is parsed; an unsigned or mis-signed token
365
+ # on a signed endpoint is rejected (fail closed). Turning signing on
366
+ # therefore invalidates in-flight cursors: clients restart from page one.
367
+ def cursor_signature(payload_token)
368
+ OpenSSL::HMAC.hexdigest("SHA256", cursor_signing_key, payload_token)
369
+ end
370
+
371
+ # nil when unsigned; the key otherwise. `true` reads
372
+ # Rails.application.secret_key_base (raising a setup hint outside Rails).
373
+ def cursor_signing_key
374
+ configured = self.class.cursor_paginatable_signed
375
+ return nil if configured == false || configured.nil?
376
+ return cursor_rails_secret_key_base if configured == true
377
+
378
+ key = configured.respond_to?(:call) ? configured.call : configured
379
+ raise ArgumentError, "#{self.class.name}: signed: resolved to a blank key" if key.to_s.strip.empty?
380
+
381
+ key.to_s
382
+ end
383
+
384
+ def cursor_rails_secret_key_base
385
+ app = defined?(Rails) && Rails.respond_to?(:application) ? Rails.application : nil
386
+ key = app.respond_to?(:secret_key_base) ? app.secret_key_base : nil
387
+ return key.to_s unless key.to_s.strip.empty?
388
+
389
+ raise ArgumentError,
390
+ "ConcernsOnRails::Controllers::CursorPaginatable: signed: true needs Rails.application.secret_key_base; " \
391
+ "outside a Rails app pass signed: -> { ... } (a callable returning the key) or a String."
392
+ end
393
+
394
+ # Splits and verifies a signed token, returning the payload half; raises
395
+ # InvalidCursor when the endpoint is signed and the token is not (or its
396
+ # signature does not match).
397
+ def verify_cursor_signature!(raw)
398
+ return raw unless cursor_signing_key
399
+
400
+ payload, signature, extra = raw.split(".", 3)
401
+ valid = extra.nil? && !payload.to_s.empty? && signature.to_s.match?(/\A[0-9a-f]{64}\z/) &&
402
+ ActiveSupport::SecurityUtils.secure_compare(signature, cursor_signature(payload))
403
+ raise InvalidCursor, "Invalid pagination cursor." unless valid
404
+
405
+ payload
343
406
  end
344
407
 
345
408
  # A NULL boundary value would emit `col > NULL` — never TRUE in SQL
@@ -375,7 +438,7 @@ module ConcernsOnRails
375
438
  def decode_cursor(raw, pairs, model, bidirectional:)
376
439
  return nil if raw.nil? || raw.to_s.strip.empty?
377
440
 
378
- payload = parse_cursor_payload(raw.to_s)
441
+ payload = parse_cursor_payload(verify_cursor_signature!(raw.to_s))
379
442
  raise InvalidCursor, "Invalid pagination cursor." unless payload
380
443
 
381
444
  verify_cursor_scope!(payload, pairs, model)