standard_pagination 0.1.1 → 0.2.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: 149b70a719e0da5a83ca318611fd7a97c8c01678022e9ca52831b2007285b138
4
- data.tar.gz: 6be9f28d0f29f6179a4a38abcebad5e985ad992a35cf08d5c0eeb1746b2edf35
3
+ metadata.gz: 488e76612676ede6f2beabac3c2bcd9c64e9568de2e69967d11fda8e704cb2ea
4
+ data.tar.gz: f06fdab7e881ecd290e20287f2f073614ad92b895e035881be0f147f98e8ac64
5
5
  SHA512:
6
- metadata.gz: 6dc0d679a00cea8898d0da4d9369c38238ffff057eb4e1e691c82f764ce781f9690083d6ce1873fe2c9a0f2dc4409e3da5c44bc4219a092892bd253340a16e4e
7
- data.tar.gz: 4f345dc7fb354212d8a4c9b02f950449fe80a8c8c99dee233bb149f770445ae0a9906e82f1443edd22b9118662c2f0a7d3ed51f3f5b6a133e21c803bc5a7b928
6
+ metadata.gz: 1c6b369a714822c0d50025ad79f7f9919ba2e2fa735172651b11fbc6edb491321b118abd00135ddbb0eb43b418d384241df6615c2815e077bbf1e696baee969d
7
+ data.tar.gz: f33f5b5a13a41e8c7169996e355e20717c24a32ad1518f7eb49bbeddefe527f5888d2e963d5d93530cabc9db4b1fe9629b2692559f77d50fa4165df718761211
data/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-10-26
11
+
12
+ ### Added
13
+ - `Controller#paginate_cursor` and `#cursor_data`: keyset (cursor) pagination for feeds, with one envelope, `{ data, next_cursor, has_more }`. The client sends `next_cursor` back as `?cursor=` (`config.cursor_key`). The primary key is appended so the order is total, and a cursor the server didn't issue is a 400 for JSON, or a redirect without it for HTML. This is the estate convention for new feeds. sidekick-web, jumpdrive and the rest had each hand-rolled their own (`has_more`/`before`/`next_page_token`, `next_cursor`, `since`).
14
+ - `config.client_limit_formats = :non_html` lets every request whose format isn't HTML choose the page size.
15
+ - `pagination_client_limit`, a class attribute that sets the `client_limit:` default for a controller and its subclasses. Set it on an API base controller: a client that sends no JSON `Accept` header is treated as HTML by Rails, so neither the JSON default nor `:non_html` would let it choose a page size. sidekick-web's sandbox endpoint passed `client_limit: true` per call for this reason.
16
+
17
+ ## [0.1.2] - 2026-10-26
18
+
19
+ ### Added
20
+ - `config.limit_key` accepts an Array of aliases, e.g. `%w[limit per_page]`; the first one the request sends wins. For APIs whose clients use both (luminality-web). The out-of-range redirect drops all of them. (Meant for 0.1.1, but that PR auto-merged before this commit landed.)
21
+
10
22
  ## [0.1.1] - 2026-10-25
11
23
 
12
24
  ### Fixed
data/README.md CHANGED
@@ -45,8 +45,9 @@ Delete `max_limit` (or `client_limit`) from `config/initializers/pagy.rb`. Leave
45
45
  StandardPagination.configure do |c|
46
46
  c.default_limit = 20 # rows when a call site passes no limit:
47
47
  c.max_limit = 100 # cap on a client-chosen page size
48
- c.limit_key = "limit" # param a client sets it with ("per_page" in luminality)
49
- c.client_limit_formats = %i[json]
48
+ c.limit_key = "limit" # param a client sets it with; an Array accepts aliases, e.g. %w[limit per_page]
49
+ c.client_limit_formats = %i[json] # or :non_html for every non-HTML request
50
+ c.cursor_key = "cursor" # param paginate_cursor reads the cursor from
50
51
  c.tiebreak = true
51
52
  c.raise_range_error = true
52
53
  c.strict_options = nil # nil = raise in dev/test, log elsewhere
@@ -69,7 +70,18 @@ render inertia: "factory/devices/index", props: {
69
70
  `paginate` options:
70
71
 
71
72
  - `limit:` is the page size. It defaults to `config.default_limit`.
72
- - `client_limit:` lets the request set the page size through `config.limit_key`. Pass an Integer cap, `true` for `config.max_limit`, or `false` to turn it off. It defaults to on for JSON and off for HTML. A request value that is junk, zero or negative falls back to `limit:`.
73
+ - `client_limit:` lets the request set the page size through `config.limit_key`. Pass an Integer cap, `true` for `config.max_limit`, or `false` to turn it off. A request value that is junk, zero or negative falls back to `limit:`. When omitted it falls back to:
74
+ 1. the controller's `pagination_client_limit`, if set;
75
+ 2. otherwise `config.client_limit_formats`: on for JSON and off for HTML by default.
76
+
77
+ An API base controller whose clients may send no `Accept: application/json` header (Rails treats those requests as HTML) should opt in once, instead of per call:
78
+
79
+ ```ruby
80
+ class Api::BaseController < ActionController::API
81
+ include StandardPagination::Controller
82
+ self.pagination_client_limit = true # or an Integer cap
83
+ end
84
+ ```
73
85
  - `tiebreak:` defaults to `config.tiebreak`.
74
86
  - `paginator:` defaults to `:offset`. `:countless` skips the `COUNT(*)` on big append-only tables. Any other pagy option, such as `page_key:` or `count:`, passes through.
75
87
 
@@ -111,6 +123,29 @@ An unordered relation gets `ORDER BY id ASC`. The relation is left alone when th
111
123
 
112
124
  Outside a paginator (a query object, or an engine scope a host will page through), use `Model.stable_order(...)`, for example `AuditLog.where(...).stable_order(occurred_at: :desc)`.
113
125
 
126
+ ## Cursor pagination
127
+
128
+ For feeds and append-heavy APIs, `paginate_cursor` uses pagy's keyset paginator: no `COUNT`, no `OFFSET`, and rows inserted while a client pages don't shift or repeat what it sees.
129
+
130
+ ```ruby
131
+ pagy, events = paginate_cursor(Event.where(device:).order(occurred_at: :desc), limit: 50)
132
+
133
+ render json: { data: events.map { EventSerializer.new(_1) }, **cursor_data(pagy) }
134
+ ```
135
+
136
+ This is the one envelope for cursor-paged responses:
137
+
138
+ ```json
139
+ { "data": [...], "next_cursor": "eyJ...", "has_more": true }
140
+ ```
141
+
142
+ - The client sends `next_cursor` back as `?cursor=` (`config.cursor_key`). `next_cursor` is `null` and `has_more` is `false` on the last page.
143
+ - Order the set with hash or symbol orders (`order(occurred_at: :desc)`), not SQL strings: pagy builds the cursor from those columns. The primary key is appended so the order is total.
144
+ - `limit:`, `client_limit:` and `tiebreak:` work as in `paginate`.
145
+ - A cursor the server didn't issue raises `Pagy::OptionError`: `rescue_pagination_errors` answers 400 for JSON, and for HTML redirects without the cursor.
146
+
147
+ Use it for new feeds, and when an existing feed is reworked. Feeds that mobile clients already parse keep their current shape until their clients move.
148
+
114
149
  ## MCP tools
115
150
 
116
151
  ```ruby
@@ -122,10 +157,10 @@ end
122
157
 
123
158
  It returns `[records, { page:, per_page:, total_count:, total_pages: }]`, the shape MCP clients already parse. A `GROUP BY` relation counts its groups, and a page past the end returns no records.
124
159
 
125
- ## Not in 0.1
160
+ ## Not in the gem
126
161
 
127
- - **Keyset or cursor pagination for feeds and APIs.** Today these are hand-rolled in sidekick-web's companion queries and in jumpdrive. 0.2 is meant to settle on a single `next_cursor` / `has_more` envelope.
128
162
  - **A shared React `<Pagination>` component.** It belongs in a frontend package, not a gem.
163
+ - **Cursor pagination for MCP list tools.** `Mcp.paginate` keeps the offset shape MCP clients already parse.
129
164
 
130
165
  ## Development
131
166
 
@@ -4,11 +4,15 @@ module StandardPagination
4
4
  attr_accessor :default_limit
5
5
  # Largest page size a client may request when client limits are on.
6
6
  attr_accessor :max_limit
7
- # Request param a client sets the page size with (when allowed).
7
+ # Request param a client sets the page size with (when allowed). An Array
8
+ # accepts aliases; the first one the request sends wins.
8
9
  attr_accessor :limit_key
9
- # Formats for which `paginate` honours the client's page size by default.
10
- # HTML pages keep the size the call site chose.
10
+ # Formats for which `paginate` honours the client's page size by default:
11
+ # an Array of format symbols, or :non_html for every request whose format
12
+ # isn't HTML. HTML pages keep the size the call site chose.
11
13
  attr_accessor :client_limit_formats
14
+ # Request param `paginate_cursor` reads the cursor from.
15
+ attr_accessor :cursor_key
12
16
  # Pagy raises Pagy::RangeError for a page past the last one, which
13
17
  # Controller.rescue_pagination_errors turns into a redirect, 404 or 400.
14
18
  # With it off, pagy serves an empty page instead.
@@ -24,6 +28,7 @@ module StandardPagination
24
28
  @max_limit = 100
25
29
  @limit_key = "limit"
26
30
  @client_limit_formats = %i[json]
31
+ @cursor_key = "cursor"
27
32
  @raise_range_error = true
28
33
  @tiebreak = true
29
34
  @strict_options = nil
@@ -19,6 +19,13 @@ module StandardPagination
19
19
  include Pagy::Method
20
20
 
21
21
  class_attribute :_pagination_error_handling, instance_accessor: false, default: nil
22
+ # The `client_limit:` default for every `paginate`/`paginate_cursor` in
23
+ # this controller and its subclasses (true, an Integer cap, or false).
24
+ # nil falls back to config.client_limit_formats. Set it on an API base
25
+ # controller, whose clients may send no JSON Accept header at all:
26
+ #
27
+ # self.pagination_client_limit = true
28
+ class_attribute :pagination_client_limit, instance_accessor: false, default: nil
22
29
  end
23
30
 
24
31
  class_methods do
@@ -92,17 +99,67 @@ module StandardPagination
92
99
  StandardPagination.data(pagy)
93
100
  end
94
101
 
102
+ # Cursor (keyset) pagination for feeds: no COUNT, no OFFSET, and rows
103
+ # inserted while a client pages don't shift what it sees. Returns
104
+ # `[pagy, records]`; send `cursor_data(pagy)` with the records.
105
+ #
106
+ # The set must be ordered with hash/symbol orders (`order(created_at:
107
+ # :desc)`), which pagy turns into the cursor's columns; the primary key
108
+ # is appended as a tie-breaker so the order is total. The client sends
109
+ # back `next_cursor` as config.cursor_key (`?cursor=`). `limit:`,
110
+ # `client_limit:` and `tiebreak:` work as in `paginate`.
111
+ #
112
+ # A cursor this didn't issue raises Pagy::OptionError, which
113
+ # rescue_pagination_errors answers with a 400 (or, for HTML, a redirect
114
+ # without it).
115
+ def paginate_cursor(set, limit: nil, client_limit: nil, tiebreak: nil, **options)
116
+ config = StandardPagination.config
117
+ tiebreak = config.tiebreak if tiebreak.nil?
118
+ set = StableOrder.apply(set) if tiebreak
119
+
120
+ cursor = params[config.cursor_key]
121
+ if cursor.present? && !(cursor.is_a?(String) && Pagy::Keyset.decode(cursor).is_a?(Array))
122
+ raise Pagy::OptionError.new(nil, config.cursor_key.to_sym, "to be a next_cursor value", cursor)
123
+ end
124
+
125
+ pagy(
126
+ :keyset,
127
+ set,
128
+ **options,
129
+ page_key: config.cursor_key.to_s,
130
+ limit: resolve_pagination_limit(limit || config.default_limit, client_limit),
131
+ max_limit: nil,
132
+ client_limit: nil,
133
+ client_max_limit: nil
134
+ )
135
+ end
136
+
137
+ # The cursor envelope's metadata: `{ next_cursor:, has_more: }`.
138
+ # `next_cursor` is nil on the last page.
139
+ #
140
+ # render json: { data: ..., **cursor_data(pagy) }
141
+ def cursor_data(pagy)
142
+ StandardPagination.cursor_data(pagy)
143
+ end
144
+
95
145
  def resolve_pagination_limit(limit, client_limit)
96
146
  cap = pagination_client_limit_cap(client_limit)
97
147
  return limit unless cap
98
148
 
99
- requested = params[StandardPagination.config.limit_key].to_s
149
+ # The first configured key the request sends wins.
150
+ key = pagination_limit_keys.find { |candidate| params[candidate].present? }
151
+ requested = key ? params[key].to_s : ""
100
152
  return limit unless requested.match?(/\A\d+\z/) && requested.to_i.positive?
101
153
 
102
154
  [ requested.to_i, cap ].min
103
155
  end
104
156
 
157
+ def pagination_limit_keys
158
+ Array(StandardPagination.config.limit_key).map(&:to_s)
159
+ end
160
+
105
161
  def pagination_client_limit_cap(client_limit)
162
+ client_limit = self.class.pagination_client_limit if client_limit.nil?
106
163
  client_limit = pagination_client_limit_format? if client_limit.nil?
107
164
 
108
165
  case client_limit
@@ -113,7 +170,9 @@ module StandardPagination
113
170
 
114
171
  def pagination_client_limit_format?
115
172
  formats = StandardPagination.config.client_limit_formats
116
- formats.any? { |format| request.format.public_send(:"#{format}?") }
173
+ return !request.format&.html? if formats == :non_html
174
+
175
+ Array(formats).any? { |format| request.format.public_send(:"#{format}?") }
117
176
  end
118
177
 
119
178
  def handle_pagination_error(exception)
@@ -144,7 +203,10 @@ module StandardPagination
144
203
  if last_page
145
204
  query[page_key] = last_page
146
205
  else
147
- query = query.except((options[:limit_key] || Pagy::DEFAULT[:limit_key]).to_s, StandardPagination.config.limit_key)
206
+ query = query.except(
207
+ (options[:limit_key] || Pagy::DEFAULT[:limit_key]).to_s, *pagination_limit_keys,
208
+ StandardPagination.config.cursor_key.to_s
209
+ )
148
210
  # Nothing left to drop: the bad option came from the call site or a
149
211
  # param this gem doesn't know, so redirecting would only repeat it.
150
212
  return head(:bad_request) if query == original
@@ -1,3 +1,3 @@
1
1
  module StandardPagination
2
- VERSION = "0.1.1"
2
+ VERSION = "0.2.0"
3
3
  end
@@ -38,6 +38,13 @@ module StandardPagination
38
38
  pagy.data_hash(data_keys: DATA_KEYS)
39
39
  end
40
40
 
41
+ # The one cursor envelope for feeds, alongside the records as `data`:
42
+ # `{ next_cursor: "<opaque>" | nil, has_more: true | false }`.
43
+ def cursor_data(pagy)
44
+ next_cursor = pagy.next
45
+ { next_cursor:, has_more: !next_cursor.nil? }
46
+ end
47
+
41
48
  # Global pagy client-limit options left over from before this gem. They
42
49
  # still apply to any bare `pagy(...)` call that bypasses
43
50
  # Controller#paginate, letting clients pick the page size.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: standard_pagination
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim