concerns_on_rails 1.26.0 → 1.28.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: 7552b47dbc09add52b4bb8d519e8a8d4ed4e49590457114003fa83b6e9b7036e
4
- data.tar.gz: b03171261ba6e24865ce46ab0c1e77a234cfec514e45e50a2dd055e82b5da287
3
+ metadata.gz: 5f9aef0d5f95ffc23ef6213d5d95ea872a6146763d488a287a334db8be68b922
4
+ data.tar.gz: b364f4276149a31b1ce1f6efaf170435900f34188eb358035c10020567e8b51d
5
5
  SHA512:
6
- metadata.gz: 461bbe636a2537cc5b60fe1eb5be3c080c115a3f119ca057a5f75d24dd54da44a6f5833a1f2baff6d2562d829fa77f98e7d1494e57cd8e1402b513f697025d61
7
- data.tar.gz: 046a10782bc2259cf8911610a13b71289aa77ce2a1164045fd524fc785b58cc6061756ea99fba3f147d6964145641f63e03ed50b8530656b9a7269a4b83ce2bf
6
+ metadata.gz: 025306bb013a418c8051db996ddd0fe6f2bf7ac7f657d22335639b5bcceecff8416e85c66ebde92a357656eeaba90f51a2993fab23232267af105e823cf28934
7
+ data.tar.gz: b0d98d45a2b189b43985e5d5548c3b905a161f2aa0df9022d251a2788b62524360bc002d2981e7e9801a10e4b0f7aa4a7b1d6b0ee5adb4298edeef89a07a8467
data/CHANGELOG.md CHANGED
@@ -1,5 +1,173 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.28.0 (2026-09-06)
4
+
5
+ Pagination is the theme: the four-PR Paginatable stack (#40, #45, #62, #83)
6
+ from the September enhancement loop. Paginatable now paginates in-memory
7
+ collections, both paginators emit RFC 8288 `Link` headers, results that were
8
+ already paginated upstream can pass `total:`, and the page / per-page parameter
9
+ names (including JSON:API's `page[number]` / `page[size]`) are configurable.
10
+ Controller-only: no new columns, migrations or runtime dependencies. 1303
11
+ examples, 0 failures.
12
+
13
+ ### Added
14
+ - **Controllers::Paginatable**: `paginated` / `pagination_meta` accept in-memory
15
+ collections — an `Array`, `Set`, `Range` or any other non-Hash `Enumerable` —
16
+ not only ActiveRecord relations. The collection is materialized once, the
17
+ total is its size and the current page comes back as an `Array` with the same
18
+ `X-Total-Count` / `X-Page` / `X-Per-Page` / `X-Total-Pages` headers and
19
+ memoized meta. Relations still paginate in SQL. A `Hash`, `nil` or a
20
+ non-collection raises `ArgumentError` naming the class received. (#40)
21
+ - **Controllers::Paginatable / CursorPaginatable**: RFC 8288 `Link` response
22
+ header — `first`/`prev`/`next`/`last` page URLs for Paginatable, `next`
23
+ (+ `prev` in bidirectional mode, `first` once a cursor is in play) for
24
+ CursorPaginatable — rebuilt from the current request with every other query
25
+ param preserved and appended to any existing `Link` header. Opt out with
26
+ `link_header: false` on either macro. Backed by the new shared
27
+ `Support::LinkHeader`. (#45)
28
+ - **Controllers::Paginatable**: `paginated(collection, total:)` /
29
+ `pagination_meta(total:)` for collections that are already one page — an
30
+ external API's or search service's page N plus its total: nothing is sliced
31
+ or counted, and `total` drives the `X-*` headers and the `Link` header.
32
+ `total:` must be a non-negative Integer. (#62)
33
+ - **Controllers::Paginatable**: `paginate_by page_param:` / `per_page_param:`
34
+ (a top-level name or a nested path such as `%i[page number]`) and
35
+ `style: :jsonapi` (`?page[number]=&page[size]=`); explicit `*_param:` options
36
+ win over the style, anything else raises at declaration. Readers dig the path
37
+ through `ScalarParam`, so a scalar where a Hash is expected falls back to the
38
+ default instead of raising. The `Link` header URLs are rebuilt under the
39
+ configured names (nested paths Rack-encoded, sibling keys preserved). (#83)
40
+
41
+ ### Notes
42
+ The `Link` header is **on by default**, so every non-empty paginated response
43
+ grows by one header. Its URLs are built from `request.base_url` + `request.path`,
44
+ i.e. whatever host Rails sees — behind a proxy configure `trusted_proxies` /
45
+ forwarded headers, or pass `link_header: false`. The `X-*` headers are
46
+ unchanged either way, and the existing `?page=&per_page=` behaviour is
47
+ byte-for-byte the same when no `page_param:` / `per_page_param:` / `style:` is
48
+ given.
49
+
50
+ ### Internal
51
+ - New `Support::LinkHeader` (autoloaded): `available?(controller)` — a request
52
+ exposing `base_url` / `path` / `query_parameters` (the dependency-free
53
+ `FakeController` has none, so emission is silently skipped); `url_for(request,
54
+ drop:, **overrides)` — Rack nested-query encoding, nested params survive, Hash
55
+ override values deep-stringified, `nil` / `drop:` removes a key;
56
+ `append(response, rels)` — never clobbers an existing `Link`.
57
+ - `Paginatable.paginate_by` moved into `module ClassMethods` alongside its new
58
+ private helpers (RuboCop scope rule; the Stateable precedent).
59
+ - Development dependencies: simplecov `~> 1.1`, sqlite3 `~> 2.9.6`, rubocop
60
+ `~> 1.89`; GitHub Actions `checkout` v7, `configure-pages` v6, `deploy-pages`
61
+ v5, `upload-pages-artifact` v5. The lockfile now resolves `permittable` 0.2.0
62
+ (the `~> 0.1` runtime pin is unchanged).
63
+
64
+ ## 1.27.0 (2026-08-29)
65
+
66
+ Scope-name collisions finally have an escape hatch on the eight concerns whose
67
+ generated scope names can collide (Activatable, Expirable, Lockable, Stateable
68
+ and Anonymizable already had it; Publishable, SoftDeletable and Schedulable
69
+ join them here), and the 1.22 batch-operation contract reaches five more
70
+ concerns. No new columns, migrations or dependencies. 1257 examples, 0
71
+ failures.
72
+
73
+ ### Added
74
+ - **Models::Publishable / SoftDeletable / Schedulable**: `prefix:`/`suffix:` on
75
+ `publishable_by` / `soft_deletable_by` / `schedulable_by` rename the generated
76
+ scopes, so a model can include SoftDeletable (`.active`) alongside Activatable
77
+ or Expirable (also `.active`) without one silently clobbering the other. With
78
+ no affix passed the scope names, default scopes and emitted SQL are unchanged.
79
+ `prefix: true` (use the configured field name), previously honoured only by
80
+ Stateable, now works on every affixing concern.
81
+ - **Models::Publishable**: `publish_all` / `unpublish_all`. `publish_all` targets
82
+ every not-currently-published row — *including scheduled ones*, whose future
83
+ timestamp it overwrites; chain the draft scope (`Post.draft.publish_all`) to
84
+ narrow it, which composes because the predicate is built against the current
85
+ relation rather than routed through the field-unscoping `unpublished` scope.
86
+ The flip side: on a model with `default_scope: true` the relation is already
87
+ narrowed to published rows, so a bare `Post.publish_all` matches nothing —
88
+ chain `.draft` or `.unpublished` (both unscope the column themselves). Both
89
+ verbs respect the relation, return an Integer count, and run in a
90
+ transaction.
91
+ - **Models::Expirable**: `expire_all(time = Time.zone.now)`.
92
+ - **Models::Activatable**: `activate_all` / `deactivate_all`.
93
+ - **Models::Lockable**: `unlock_expired` — clears `locked_at` and zeroes the
94
+ attempts counter on every row whose `unlock_in` window has elapsed, mirroring
95
+ `unlock_access!`. Returns 0 without querying when `unlock_in` is nil.
96
+ - **Models::Stateable**: `transition_all(event)` — runs one declared transition
97
+ across the relation, skipping (not failing) records the guard rejects.
98
+ Deliberately has no single-UPDATE fast path: the per-record path runs
99
+ validations through `update!` and a bulk UPDATE would skip them.
100
+
101
+ ### Notes
102
+ Validation semantics of the new batch verbs (`publish_all`, `unpublish_all`,
103
+ `expire_all`, `activate_all`, `deactivate_all`, `unlock_expired`): each
104
+ collapses to a **single `UPDATE`** — one SQL statement for the whole batch —
105
+ only when the host model declares **no validations** (and has overridden
106
+ none of the concern's hooks/bang methods; see the per-concern docs for the
107
+ exact method list). "No validations" means neither `validates` /
108
+ `validates_with` **nor** a custom `validate :method` / `validate do … end` —
109
+ the latter registers only a validate callback and leaves `validators` empty,
110
+ so it is detected through `_validate_callbacks` rather than `validators`.
111
+ It also means **no association carrying the default autosave validation**: a
112
+ bare `has_many`/`has_one` registers a `validate_associated_records_*` callback,
113
+ so in practice most models with associations take the streaming per-record
114
+ path. That is deliberate — the gate errs toward the path that honours the
115
+ rollback contract — but it means the single-`UPDATE` optimisation applies to
116
+ simple models, not to every model that merely omits `validates`.
117
+ `unlock_expired` is exempt from the validations check because
118
+ `unlock_access!` writes via `update_columns`, which always skips validations,
119
+ so its two paths are already equivalent. On a model that declares any
120
+ validation, five of these verbs (`publish_all`, `unpublish_all`, `expire_all`,
121
+ `activate_all`, `deactivate_all`) instead stream the relation record-by-record
122
+ inside a transaction, calling the same guarded bang/update method a single
123
+ record would use; a record that fails to save raises
124
+ `ActiveRecord::RecordNotSaved` and rolls the **entire batch** back — nothing
125
+ partially commits.
126
+
127
+ The single-`UPDATE` path writes `updated_at` (`updated_on` too, when present)
128
+ alongside the concern's own column whenever the model has that column and
129
+ `record_timestamps` is on, so the fast and slow paths agree — the same thing
130
+ Rails' `touch_all` and `update_counters(touch:)` do. `unlock_expired` is
131
+ exempt here as well: it mirrors `unlock_access!`, which writes via
132
+ `update_columns` and deliberately does not touch timestamps.
133
+
134
+ `transition_all` also always takes the per-record path, regardless of
135
+ validators — for the same underlying reason (validations must run) — but its
136
+ failure mode is different, not the same: the per-record path calls the
137
+ guarded `<event>!`, which calls `update!`, and `update!` raises
138
+ `ActiveRecord::RecordInvalid` **directly** on a validation failure. That
139
+ exception propagates straight out of the batch loop, never reaching the
140
+ `RecordNotSaved` branch the other five verbs use. Code that rescues
141
+ `RecordNotSaved` around a `transition_all` call will not catch a failed row —
142
+ rescue `RecordInvalid` there instead.
143
+
144
+ One residual, deliberate divergence survives on the fast path: like every
145
+ `*_all` method in Rails, the single-UPDATE path does not fire host-defined
146
+ `before_save`/`after_save` callbacks (only the concern's own
147
+ `before_*`/`after_*` lifecycle hooks are checked when deciding whether the
148
+ fast path applies at all). If your model relies on `before_save`/`after_save`
149
+ for side effects, either add a validation (forcing the slow, per-record path)
150
+ or call the bang method in a loop.
151
+
152
+ ### Internal
153
+ - New `Support::Affix` (affixed-name computation, `prefix: true` normalization,
154
+ and the guarded scope capture/retirement used by the three newly affixable
155
+ concerns) replaces six duplicated implementations across Activatable,
156
+ Expirable, Lockable, Anonymizable, Stateable and Storable.
157
+ - New `Support::BatchOps`: the whole fast-path safety decision
158
+ (`fast_path?` = hooks/bang methods unoverridden AND no declared validations,
159
+ detected through both `validators` and `_validate_callbacks`), the
160
+ ownership-only half (`unoverridden?`, for the two concerns whose per-record
161
+ path writes via `update_column(s)` and so needs no validations gate), the
162
+ `updated_at`/`updated_on` bulk-write payload helper (`with_timestamps`), and
163
+ the transactional `find_each` runner. SoftDeletable's `soft_delete_all` /
164
+ `restore_all` now route through it, so the contract has one definition.
165
+ - Retiring a default-named scope is guarded three ways — the name must have been
166
+ recorded by the concern, be owned by the class's own singleton, and still be
167
+ the exact method captured — so a model's own override survives and a parent's
168
+ scopes are never removed from a subclass (that case raises with a pointer to
169
+ the parent).
170
+
3
171
  ## 1.26.0 (2026-08-24)
4
172
 
5
173
  A fixes-and-performance round from a full audit of the 25 model concerns: one privacy bug (Anonymizable left Encryptable blind-index fingerprints queryable after erasure), four silent-misbehavior fixes, five query-count reductions, and three hardening changes. No new concerns. 1173 examples, 0 failures.
data/README.md CHANGED
@@ -358,6 +358,58 @@ publishable_by :published_at, default_scope: true
358
358
  # Article.unscoped reaches everything
359
359
  ```
360
360
 
361
+ **Bulk operations**
362
+
363
+ ```ruby
364
+ Post.draft.publish_all # publishes every not-currently-published post; returns the count
365
+ Post.published.unpublish_all # unpublishes every currently-published post; returns the count
366
+ ```
367
+
368
+ Both respect the current relation, return an Integer count, and run in a transaction — a
369
+ record that fails to save raises `ActiveRecord::RecordNotSaved` and rolls the whole batch
370
+ back. With no overridden `before_publish`/`after_publish`/`before_unpublish`/`after_unpublish`/
371
+ `publish!`/`unpublish!` and no validations on the model — neither `validates`/`validates_with`,
372
+ a custom `validate :method`, nor an association's autosave validation (a bare `has_many`
373
+ registers one, so most models with associations take the streaming path) — both collapse to a
374
+ single `UPDATE`, which bumps `updated_at`
375
+ exactly as the per-record path does; otherwise they stream per record through
376
+ `publish!`/`unpublish!` so validations still run.
377
+
378
+ `publish_all` targets every not-currently-published row — **including scheduled ones**, whose
379
+ future `published_at` it overwrites — so chain `.draft` (`Post.draft.publish_all`) to exclude
380
+ them. Its predicate composes with your own constraint on the publish column, so that chain
381
+ really does leave scheduled rows alone. The flip side of composing: with `default_scope: true`
382
+ the relation is already narrowed to published rows, so a bare `Post.publish_all` matches
383
+ nothing — chain `.draft` or `.unpublished` first (both unscope the column themselves, so the
384
+ chain resolves to the rows you mean).
385
+
386
+ **Scope-name collisions**
387
+
388
+ ```ruby
389
+ publishable_by :published_at, prefix: :article # => Article.article_published / .article_draft
390
+ publishable_by :published_at, suffix: :posts # => Article.published_posts / .draft_posts
391
+ publishable_by :published_at, prefix: true # => Article.published_at_published / ...
392
+ ```
393
+
394
+ `prefix:`/`suffix:` rename every scope `publishable_by` generates, so a model can include
395
+ Publishable alongside another concern that would otherwise generate a same-named scope
396
+ (SoftDeletable and Activatable also define `.active`, for instance) without one clobbering
397
+ the other. With no affix passed, scope names, the optional default scope, and the emitted
398
+ SQL are all unchanged.
399
+
400
+ `prefix:`/`suffix:` mean three different things across the gem, depending on the concern:
401
+ - **Scope-name affix** (renames generated scopes) — Activatable, Expirable, Lockable,
402
+ Stateable, Anonymizable, Publishable, SoftDeletable, Schedulable.
403
+ - **Accessor-name affix** (renames generated reader/writer methods) — Storable.
404
+ - **A literal string prepended to the generated value itself** — Sequenceable's `prefix:`
405
+ (e.g. `"INV-"`), unrelated to scope/method naming.
406
+
407
+ Passing `true` for a scope- or accessor-name affix means "use the configured field name"
408
+ (`publishable_by :published_at, prefix: true` → `published_at_published`/`published_at_draft`).
409
+
410
+ Unrelated to all three: Searchable's `match: :prefix` is a LIKE-match mode (`term%`), not a
411
+ naming affix.
412
+
361
413
  **Notes**
362
414
  - "Published" means `published_at` is set **and** in the past — so future-dated posts stay unpublished until their time arrives.
363
415
  - No `default_scope` is added by default; chain `.published` explicitly (or opt in with `default_scope: true`).
@@ -412,6 +464,21 @@ collapse to a single `UPDATE`. Note that `really_destroy_all` peels the soft-del
412
464
  predicate off the relation, so `only_deleted.really_destroy_all` widens to the whole
413
465
  relation — purge trash with `User.soft_deleted.delete_all` instead.
414
466
 
467
+ **Scope-name collisions**
468
+
469
+ ```ruby
470
+ soft_deletable_by :deleted_at, prefix: :account # => .account_active / .account_soft_deleted / ...
471
+ soft_deletable_by :deleted_at, suffix: :records # => .active_records / .soft_deleted_records / ...
472
+ soft_deletable_by :deleted_at, prefix: true # => .deleted_at_active / ...
473
+ ```
474
+
475
+ `prefix:`/`suffix:` rename every scope `soft_deletable_by` generates (`active`,
476
+ `without_deleted`, `soft_deleted`, `only_deleted`, `with_deleted`, `deleted_within`), so a
477
+ model can combine SoftDeletable with another concern that also defines `.active` (Activatable,
478
+ Expirable) without a collision. `prefix: true` uses the configured field name. With no affix
479
+ passed, scope names, the default scope, and the emitted SQL are unchanged. See the
480
+ Publishable section above for how `prefix:`/`suffix:` differ across the gem.
481
+
415
482
  **Lifecycle hooks** — override these methods on the model:
416
483
 
417
484
  ```ruby
@@ -512,6 +579,18 @@ promo.reschedule!(starts_at: 1.day.from_now,
512
579
  ends_at: 2.days.from_now)
513
580
  ```
514
581
 
582
+ **Scope-name collisions**
583
+
584
+ ```ruby
585
+ schedulable_by prefix: :promo # => .promo_current / .promo_upcoming / .promo_expired / .promo_active_at
586
+ schedulable_by suffix: :window # => .current_window / .upcoming_window / .expired_window / .active_at_window
587
+ schedulable_by prefix: true # => .starts_at_current / ... (the configured starts_at/ends_at field)
588
+ ```
589
+
590
+ `prefix:`/`suffix:` rename every scope `schedulable_by` generates (`active_at`, `current`,
591
+ `upcoming`, `expired`). With no affix passed, scope names and the emitted SQL are unchanged.
592
+ See the Publishable section above for how `prefix:`/`suffix:` differ across the gem.
593
+
515
594
  **Notes**
516
595
  - Boundary semantics: **inclusive start, exclusive end** — active at exactly `starts_at`, not at exactly `ends_at`.
517
596
  - A `nil` end means "never expires"; a `nil` start means "not yet started".
@@ -552,6 +631,21 @@ token.extend_expiry!(by: 1.day) # pushes expiry forward
552
631
  - If `expires_at` is `nil` or in the past → new value is `now + by`
553
632
  - If `expires_at` is still in the future → `by` is added to the existing value
554
633
 
634
+ **Bulk operations**
635
+
636
+ ```ruby
637
+ ApiToken.expiring_within(1.day).expire_all # => 12
638
+ ```
639
+
640
+ `expire_all(time = Time.zone.now)` expires every currently-active record in the relation and
641
+ returns the Integer count, in a transaction. With `expire!` unoverridden and no validations on
642
+ the model — neither `validates`/`validates_with`, a custom `validate :method`, nor an
643
+ association's autosave validation (a bare `has_many` registers one, so most models with
644
+ associations take the streaming path) — it collapses
645
+ to a single `UPDATE`, which bumps `updated_at` exactly as the per-record path does; otherwise it
646
+ streams per record through `expire!` so validations still run, and a record that fails to save
647
+ raises `ActiveRecord::RecordNotSaved` and rolls the whole batch back.
648
+
555
649
  **Custom field name**
556
650
 
557
651
  ```ruby
@@ -659,6 +753,23 @@ Subscription.active # WHERE active = TRUE
659
753
  Subscription.inactive # WHERE active = FALSE OR active IS NULL
660
754
  ```
661
755
 
756
+ **Bulk operations**
757
+
758
+ ```ruby
759
+ Subscription.inactive.activate_all # => 12
760
+ Subscription.active.deactivate_all # => 3
761
+ ```
762
+
763
+ Both target the relation, return an Integer count, and run in a transaction. With
764
+ `activate!`/`deactivate!` unoverridden and no validations on the model — neither
765
+ `validates`/`validates_with`, a custom `validate :method`, nor an association's autosave
766
+ validation (a bare `has_many` registers one, so most models with associations take the
767
+ streaming path) — they collapse to a single
768
+ `UPDATE`, which bumps `updated_at` exactly as the per-record path does; otherwise they stream
769
+ per record so validations still run, and a record that fails to save raises
770
+ `ActiveRecord::RecordNotSaved` and rolls the whole batch back. `toggle_active!`'s row lock has
771
+ no batch analogue.
772
+
662
773
  **Notes**
663
774
  - `NULL` is treated as inactive (same convention as most apps' "unset = off").
664
775
  - The configured column must exist; `activatable_by` raises `ArgumentError` otherwise.
@@ -805,6 +916,18 @@ article.may_publish? # => true (guard check without raising)
805
916
  article.transition_to!(:archived) # generic move to any declared state
806
917
  ```
807
918
 
919
+ **Bulk operations**
920
+
921
+ ```ruby
922
+ Article.draft.transition_all(:publish) # => 12 — runs :publish across every eligible row
923
+ ```
924
+
925
+ Returns the Integer count of records transitioned, running in a transaction; records the
926
+ guard rejects are skipped (not errors). Unlike every other batch verb in this gem,
927
+ `transition_all` has **no single-UPDATE fast path** — it always streams per record through
928
+ the guarded `<event>!` method, because that path runs validations via `update!` while a bulk
929
+ `update_all` would silently skip them.
930
+
808
931
  **Prefix / suffix** — avoid clashes when the state names overlap with other concerns or scopes:
809
932
 
810
933
  ```ruby
@@ -1094,10 +1217,21 @@ user.reset_failed_attempts! # call on successful login
1094
1217
  user.lock_access! # manual lock (hooks: before/after_lock)
1095
1218
  user.unlock_access! # manual unlock (hooks: before/after_unlock)
1096
1219
  User.locked / User.unlocked # expiry-aware scopes
1220
+
1221
+ User.unlock_expired # => 3 — unlocks every row whose unlock_in window has elapsed
1097
1222
  ```
1098
1223
 
1099
1224
  **Options**: `attempts:` (`:failed_attempts`, must be an integer column), `locked_at:` (`:locked_at`, datetime column), `max_attempts:` (`5`; `nil` = count but never auto-lock), `unlock_in:` (`nil` = locked until manual unlock; a duration makes the lock lapse by itself), `prefix:` / `suffix:` (affix the scope names).
1100
1225
 
1226
+ **Bulk operations**
1227
+
1228
+ `unlock_expired` clears `locked_at` and zeroes the attempts counter on every row whose
1229
+ `locked_at + unlock_in` has passed, exactly as `unlock_access!` does — returns the Integer
1230
+ count, runs in a transaction. Returns `0` without querying when `unlock_in` is `nil` (manual
1231
+ unlock only). Because `unlock_access!` persists via `update_columns` (which already skips
1232
+ validations), the single-`UPDATE` fast path and the per-record path are equivalent here —
1233
+ unlike Publishable/Expirable/Activatable, this one has no validators gate.
1234
+
1101
1235
  **Notes**
1102
1236
  - The increment is SQL-side (`COALESCE(attempts, 0) + 1` via `update_counters`), so concurrent failures never lose updates and a NULL counter needs no column default; a locked account stops counting.
1103
1237
  - Expiry is **lazy**: readers and scopes treat a stale lock as unlocked but never write. The column is cleared by the next `unlock_access!` or failed attempt (quietly there — no unlock hooks fire from a failed login).
@@ -1321,6 +1455,30 @@ class ArticlesController < ApplicationController
1321
1455
  end
1322
1456
  ```
1323
1457
 
1458
+ **Arrays and other Enumerables work too.** Results that never touched the database — an
1459
+ external API response, a loaded association, a hand-built list of Structs — get the same
1460
+ slicing, headers and `pagination_meta`. Relations still paginate in SQL (`LIMIT`/`OFFSET`);
1461
+ an in-memory collection is sliced in Ruby and comes back as an `Array`:
1462
+
1463
+ ```ruby
1464
+ def search
1465
+ render json: paginated(ExternalCatalog.search(params[:q])) # Array in, current page out
1466
+ end
1467
+ ```
1468
+
1469
+ Anything answering `limit`/`offset` is treated as a relation; any other non-`Hash` `Enumerable`
1470
+ (`Array`, `Set`, `Range`, `Enumerator` — consumed once) is materialized and sliced. A `Hash`,
1471
+ `nil` or a non-collection raises `ArgumentError` (call `.to_a` to paginate a Hash's pairs).
1472
+
1473
+ **Already paginated upstream?** When an external API or search service hands you page N and the
1474
+ total it counted, pass `total:` — nothing is sliced, limited or counted; the collection comes back
1475
+ as-is and `total` drives `X-Total-Count`, `X-Total-Pages` and the `Link` header:
1476
+
1477
+ ```ruby
1478
+ result = Catalog.search(params[:q], page: params[:page], per_page: params[:per_page])
1479
+ render_success(data: paginated(result.hits, total: result.total_hits), meta: pagination_meta)
1480
+ ```
1481
+
1324
1482
  **URL params**
1325
1483
 
1326
1484
  | Param | Default | Notes |
@@ -1328,7 +1486,19 @@ end
1328
1486
  | `?page=` | `1` | Page numbers below 1 are clamped to 1 |
1329
1487
  | `?per_page=` | `25` | Capped at `max_per_page` (default 200) |
1330
1488
 
1331
- **Response headers**: `X-Total-Count`, `X-Page`, `X-Per-Page`, `X-Total-Pages`.
1489
+ Rename them, or speak JSON:API — the `Link` header URLs follow whatever you pick:
1490
+
1491
+ ```ruby
1492
+ paginate_by page_param: :p, per_page_param: :limit # ?p=2&limit=10
1493
+ paginate_by style: :jsonapi # ?page[number]=2&page[size]=10
1494
+ paginate_by page_param: %i[paging page], per_page_param: %i[paging per] # any nested path
1495
+ ```
1496
+
1497
+ **Response headers**: `X-Total-Count`, `X-Page`, `X-Per-Page`, `X-Total-Pages`, and an RFC 8288 `Link`
1498
+ header with `first` / `prev` / `next` / `last` URLs rebuilt from the current request (other query params
1499
+ preserved; `prev`/`next` only when such a page exists; nothing for an empty collection) — the GitHub
1500
+ convention, so clients follow links instead of computing page numbers. Appended to any `Link` header
1501
+ already set (Deprecatable, CDN hints). `paginate_by link_header: false` turns it off.
1332
1502
 
1333
1503
  ---
1334
1504
 
@@ -1356,7 +1526,7 @@ end
1356
1526
  | `?per_page=` | `25` | Capped at `max_per_page` (default 200; `0` disables the cap) |
1357
1527
  | `?order=` | first preset | With `order_presets:` only — selects a named ordering from the allow-list (unknown names → 400 `invalid_order_preset`) |
1358
1528
 
1359
- **Response headers**: `X-Per-Page`, `X-Count` (rows on **this** page — totals are deliberately not computed), `X-Has-More`, `X-Next-Cursor` (only while more pages exist). With `bidirectional: true`: also `X-Has-Prev`, `X-Prev-Cursor`.
1529
+ **Response headers**: `X-Per-Page`, `X-Count` (rows on **this** page — totals are deliberately not computed), `X-Has-More`, `X-Next-Cursor` (only while more pages exist). With `bidirectional: true`: also `X-Has-Prev`, `X-Prev-Cursor`. Plus an RFC 8288 `Link` header: `rel="next"` carries the next-cursor URL, `rel="prev"` the prev-cursor URL (bidirectional), `rel="first"` the current URL with the cursor dropped (once a cursor is in play); `per_page` and the order preset are preserved. `cursor_paginate_by link_header: false` turns it off.
1360
1530
 
1361
1531
  **Notes**
1362
1532
  - 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).
@@ -1952,9 +2122,9 @@ Point your agent at `llms.txt` for an overview, or paste a single concern's `.md
1952
2122
 
1953
2123
  ```sh
1954
2124
  bundle install # install dev dependencies
1955
- bundle exec rspec # run the test suite (1,173 examples)
2125
+ bundle exec rspec # run the test suite (1,303 examples)
1956
2126
  gem build concerns_on_rails.gemspec # build the gem
1957
- gem install ./concerns_on_rails-1.26.0.gem # install locally
2127
+ gem install ./concerns_on_rails-1.28.0.gem # install locally
1958
2128
 
1959
2129
  # Preview the docs site locally (GitHub Pages serves docs/ as-is):
1960
2130
  cd docs && python3 -m http.server 8000 # → http://localhost:8000
@@ -2,6 +2,7 @@ require "active_support/concern"
2
2
  require "concerns_on_rails/support/error_envelope"
3
3
  require "concerns_on_rails/support/scalar_param"
4
4
  require "json"
5
+ require "concerns_on_rails/support/link_header"
5
6
  require "time" # Time#iso8601(fraction_digits) lives in the stdlib time library
6
7
 
7
8
  module ConcernsOnRails
@@ -141,6 +142,7 @@ module ConcernsOnRails
141
142
  class_attribute :cursor_paginatable_max_per_page, default: DEFAULT_MAX_PER_PAGE
142
143
  class_attribute :cursor_paginatable_bidirectional, default: false
143
144
  class_attribute :cursor_paginatable_predicate, default: :auto
145
+ class_attribute :cursor_paginatable_link_header, default: true
144
146
 
145
147
  # Real controllers (anything with ActiveSupport::Rescuable) get the 400
146
148
  # handlers automatically; bare objects let the errors propagate.
@@ -161,7 +163,7 @@ module ConcernsOnRails
161
163
  # max_per_page: 0 (or negative) disables the per_page cap.
162
164
  def cursor_paginate_by(order: nil, order_presets: nil, default_preset: nil, order_param: :order,
163
165
  per_page: DEFAULT_PER_PAGE, max_per_page: DEFAULT_MAX_PER_PAGE,
164
- bidirectional: false, predicate: :auto)
166
+ bidirectional: false, predicate: :auto, link_header: true)
165
167
  self.cursor_paginatable_order = order && CursorPaginatable.normalize_order!(order)
166
168
  self.cursor_paginatable_order_presets = order_presets && CursorPaginatable.normalize_presets!(order_presets)
167
169
  self.cursor_paginatable_default_preset =
@@ -171,6 +173,7 @@ module ConcernsOnRails
171
173
  self.cursor_paginatable_max_per_page = max_per_page.to_i
172
174
  self.cursor_paginatable_bidirectional = bidirectional ? true : false
173
175
  self.cursor_paginatable_predicate = CursorPaginatable.validate_predicate!(predicate)
176
+ self.cursor_paginatable_link_header = link_header ? true : false
174
177
  end
175
178
  end
176
179
 
@@ -506,11 +509,31 @@ module ConcernsOnRails
506
509
  response.set_header("X-Count", meta[:count].to_s)
507
510
  response.set_header("X-Has-More", meta[:has_more].to_s)
508
511
  response.set_header("X-Next-Cursor", meta[:next_cursor]) if meta[:next_cursor]
512
+ apply_cursor_pagination_links(meta)
509
513
  return unless meta.key?(:has_prev)
510
514
 
511
515
  response.set_header("X-Has-Prev", meta[:has_prev].to_s)
512
516
  response.set_header("X-Prev-Cursor", meta[:prev_cursor]) if meta[:prev_cursor]
513
517
  end
518
+
519
+ # RFC 8288 Link: rel="next" carries the X-Next-Cursor token, rel="prev"
520
+ # the X-Prev-Cursor one (bidirectional mode), rel="first" is the current
521
+ # URL with the cursor dropped — emitted once a cursor is in play. Other
522
+ # query params (per_page, the order preset) are preserved. Skipped when
523
+ # disabled or without a real request.
524
+ def apply_cursor_pagination_links(meta)
525
+ return unless self.class.cursor_paginatable_link_header
526
+ return unless ConcernsOnRails::Support::LinkHeader.available?(self)
527
+
528
+ cursor_url = ->(token) { ConcernsOnRails::Support::LinkHeader.url_for(request, cursor: token) }
529
+ raw_cursor = params[:cursor]
530
+ ConcernsOnRails::Support::LinkHeader.append(
531
+ response,
532
+ first: ConcernsOnRails::Support::ScalarParam.scalar?(raw_cursor) && !raw_cursor.to_s.empty? ? cursor_url.call(nil) : nil,
533
+ prev: meta[:prev_cursor] && cursor_url.call(meta[:prev_cursor]),
534
+ next: meta[:next_cursor] && cursor_url.call(meta[:next_cursor])
535
+ )
536
+ end
514
537
  end
515
538
  end
516
539
  end