standard_pagination 0.1.2 → 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: c8213902bdeb4fed07cf6c759eb52fc064a4f62154a25a7c92dafba705232a7d
4
- data.tar.gz: 6fc78271275b84da5b601836cd6b03e100545b43a8c79ffc6ef8ea30a761e139
3
+ metadata.gz: 488e76612676ede6f2beabac3c2bcd9c64e9568de2e69967d11fda8e704cb2ea
4
+ data.tar.gz: f06fdab7e881ecd290e20287f2f073614ad92b895e035881be0f147f98e8ac64
5
5
  SHA512:
6
- metadata.gz: 41c6e457018dd8e2c9d320cd7ea298a7c8812cefd64ff47d1828d12851cdcbe6e45da01add2a7cf70b8cda166789b4d851dec5812cc6f8fb8443e6737f914f50
7
- data.tar.gz: 631d2e4e9ad5bd57077073417663f19e709912171918b6dd39feaf8370848f7d9fb6ac8ea228ec41b5fe334a992393593a47b622c450c6bfbe7204d78c6efd4d
6
+ metadata.gz: 1c6b369a714822c0d50025ad79f7f9919ba2e2fa735172651b11fbc6edb491321b118abd00135ddbb0eb43b418d384241df6615c2815e077bbf1e696baee969d
7
+ data.tar.gz: f33f5b5a13a41e8c7169996e355e20717c24a32ad1518f7eb49bbeddefe527f5888d2e963d5d93530cabc9db4b1fe9629b2692559f77d50fa4165df718761211
data/CHANGELOG.md CHANGED
@@ -7,6 +7,13 @@ 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
+
10
17
  ## [0.1.2] - 2026-10-26
11
18
 
12
19
  ### Added
data/README.md CHANGED
@@ -46,7 +46,8 @@ 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
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]
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
 
@@ -7,9 +7,12 @@ module StandardPagination
7
7
  # Request param a client sets the page size with (when allowed). An Array
8
8
  # accepts aliases; the first one the request sends wins.
9
9
  attr_accessor :limit_key
10
- # Formats for which `paginate` honours the client's page size by default.
11
- # 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.
12
13
  attr_accessor :client_limit_formats
14
+ # Request param `paginate_cursor` reads the cursor from.
15
+ attr_accessor :cursor_key
13
16
  # Pagy raises Pagy::RangeError for a page past the last one, which
14
17
  # Controller.rescue_pagination_errors turns into a redirect, 404 or 400.
15
18
  # With it off, pagy serves an empty page instead.
@@ -25,6 +28,7 @@ module StandardPagination
25
28
  @max_limit = 100
26
29
  @limit_key = "limit"
27
30
  @client_limit_formats = %i[json]
31
+ @cursor_key = "cursor"
28
32
  @raise_range_error = true
29
33
  @tiebreak = true
30
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,6 +99,49 @@ 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
@@ -109,6 +159,7 @@ module StandardPagination
109
159
  end
110
160
 
111
161
  def pagination_client_limit_cap(client_limit)
162
+ client_limit = self.class.pagination_client_limit if client_limit.nil?
112
163
  client_limit = pagination_client_limit_format? if client_limit.nil?
113
164
 
114
165
  case client_limit
@@ -119,7 +170,9 @@ module StandardPagination
119
170
 
120
171
  def pagination_client_limit_format?
121
172
  formats = StandardPagination.config.client_limit_formats
122
- 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}?") }
123
176
  end
124
177
 
125
178
  def handle_pagination_error(exception)
@@ -150,7 +203,10 @@ module StandardPagination
150
203
  if last_page
151
204
  query[page_key] = last_page
152
205
  else
153
- query = query.except((options[:limit_key] || Pagy::DEFAULT[:limit_key]).to_s, *pagination_limit_keys)
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
+ )
154
210
  # Nothing left to drop: the bad option came from the call site or a
155
211
  # param this gem doesn't know, so redirecting would only repeat it.
156
212
  return head(:bad_request) if query == original
@@ -1,3 +1,3 @@
1
1
  module StandardPagination
2
- VERSION = "0.1.2"
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.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim