@zerotal/arch 1.7.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.
- package/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/api-surface.md +283 -0
- package/docs/about.md +676 -0
- package/docs/admin/actions.md +183 -0
- package/docs/admin/auth.md +89 -0
- package/docs/admin/dashboard.md +155 -0
- package/docs/admin/extending-ui.md +229 -0
- package/docs/admin/extending.md +205 -0
- package/docs/admin/forms.md +155 -0
- package/docs/admin/index.md +144 -0
- package/docs/admin/operations.md +224 -0
- package/docs/admin/references.md +174 -0
- package/docs/admin/resources.md +132 -0
- package/docs/admin/structure.md +158 -0
- package/docs/admin/tables.md +185 -0
- package/docs/admin/testing.md +138 -0
- package/docs/ai.md +373 -0
- package/docs/application.md +435 -0
- package/docs/arch.md +263 -0
- package/docs/assets.md +755 -0
- package/docs/audit.md +367 -0
- package/docs/authentication.md +1000 -0
- package/docs/authorization.md +529 -0
- package/docs/broadcasting/channels.md +170 -0
- package/docs/broadcasting/client.md +108 -0
- package/docs/broadcasting/events.md +190 -0
- package/docs/broadcasting/index.md +163 -0
- package/docs/broadcasting/references.md +84 -0
- package/docs/broadcasting/testing.md +132 -0
- package/docs/cache.md +387 -0
- package/docs/carbon.md +830 -0
- package/docs/changelog.md +465 -0
- package/docs/client/auth.md +113 -0
- package/docs/client/errors.md +139 -0
- package/docs/client/files.md +118 -0
- package/docs/client/index.md +128 -0
- package/docs/client/references.md +58 -0
- package/docs/client/requests.md +131 -0
- package/docs/client/resilience.md +141 -0
- package/docs/client/testing.md +146 -0
- package/docs/commands.md +450 -0
- package/docs/components.md +2427 -0
- package/docs/config-system.md +258 -0
- package/docs/container.md +596 -0
- package/docs/context.md +579 -0
- package/docs/contributing.md +164 -0
- package/docs/controllers.md +354 -0
- package/docs/conventions.md +466 -0
- package/docs/cookies.md +101 -0
- package/docs/csrf.md +206 -0
- package/docs/database.md +476 -0
- package/docs/deployment.md +434 -0
- package/docs/devtools.md +1013 -0
- package/docs/email-verification.md +320 -0
- package/docs/encryption.md +380 -0
- package/docs/errors.md +317 -0
- package/docs/events.md +420 -0
- package/docs/flow/components.md +846 -0
- package/docs/flow/decorators.md +458 -0
- package/docs/flow/events.md +420 -0
- package/docs/flow/forms.md +551 -0
- package/docs/flow/index.md +568 -0
- package/docs/flow/layouts.md +793 -0
- package/docs/flow/lifecycle.md +336 -0
- package/docs/flow/pagination.md +402 -0
- package/docs/flow/performance.md +262 -0
- package/docs/flow/references.md +317 -0
- package/docs/flow/routing.md +550 -0
- package/docs/flow/testing.md +447 -0
- package/docs/getting-started.md +286 -0
- package/docs/health.md +269 -0
- package/docs/helpers.md +423 -0
- package/docs/i18n.md +414 -0
- package/docs/index.md +65 -0
- package/docs/inertia/build.md +95 -0
- package/docs/inertia/devtools.md +197 -0
- package/docs/inertia/index.md +267 -0
- package/docs/inertia/middleware.md +81 -0
- package/docs/inertia/props.md +507 -0
- package/docs/inertia/references.md +144 -0
- package/docs/inertia/rendering.md +189 -0
- package/docs/inertia/ssr.md +102 -0
- package/docs/inspirations.md +257 -0
- package/docs/lifecycle.md +331 -0
- package/docs/lock.md +454 -0
- package/docs/logger.md +517 -0
- package/docs/media.md +582 -0
- package/docs/middleware.md +509 -0
- package/docs/migrations.md +529 -0
- package/docs/monitor.md +377 -0
- package/docs/notifications.md +848 -0
- package/docs/orm/casts.md +365 -0
- package/docs/orm/factories.md +231 -0
- package/docs/orm/index.md +663 -0
- package/docs/orm/lifecycle.md +421 -0
- package/docs/orm/queries.md +692 -0
- package/docs/orm/relationships.md +440 -0
- package/docs/orm/serialization.md +251 -0
- package/docs/package-development.md +536 -0
- package/docs/pagination.md +340 -0
- package/docs/password-reset.md +335 -0
- package/docs/providers.md +447 -0
- package/docs/query-builder.md +465 -0
- package/docs/queue.md +549 -0
- package/docs/rate-limiting.md +253 -0
- package/docs/responses.md +336 -0
- package/docs/roles-and-2fa.md +418 -0
- package/docs/routing.md +1008 -0
- package/docs/scaffolding.md +335 -0
- package/docs/scheduler.md +533 -0
- package/docs/seeding.md +229 -0
- package/docs/session.md +451 -0
- package/docs/social.md +524 -0
- package/docs/storage.md +622 -0
- package/docs/structure.md +230 -0
- package/docs/support-policy.md +114 -0
- package/docs/telemetry.md +505 -0
- package/docs/tenancy.md +608 -0
- package/docs/testing/browser.md +153 -0
- package/docs/testing/console.md +130 -0
- package/docs/testing/database.md +238 -0
- package/docs/testing/flow-browser.md +216 -0
- package/docs/testing/http.md +441 -0
- package/docs/testing/index.md +240 -0
- package/docs/testing/mocking.md +410 -0
- package/docs/upgrade.md +192 -0
- package/docs/validator.md +549 -0
- package/docs/view.md +453 -0
- package/package.json +60 -0
- package/src/bin/mcp.ts +73 -0
- package/src/config.ts +63 -0
- package/src/errors.ts +27 -0
- package/src/index.ts +56 -0
- package/src/install/ArchInstallCommand.ts +189 -0
- package/src/install/detect.ts +98 -0
- package/src/install/guidelines.ts +290 -0
- package/src/install/markers.ts +77 -0
- package/src/install/mcpConfig.ts +81 -0
- package/src/mcp/index.ts +27 -0
- package/src/mcp/jsonrpc.ts +133 -0
- package/src/mcp/server.ts +307 -0
- package/src/mcp/stdio.ts +111 -0
- package/src/mcp/types.ts +146 -0
- package/src/probe/ArchProbeCommand.ts +48 -0
- package/src/probe/sentinel.ts +13 -0
- package/src/probe/topics.ts +291 -0
- package/src/provider/ArchProvider.ts +101 -0
- package/src/tools/_probe.ts +164 -0
- package/src/tools/apiSurface.ts +241 -0
- package/src/tools/appInfo.ts +88 -0
- package/src/tools/baselines.ts +210 -0
- package/src/tools/context.ts +37 -0
- package/src/tools/doctor.ts +93 -0
- package/src/tools/index.ts +50 -0
- package/src/tools/logs.ts +265 -0
- package/src/tools/routes.ts +133 -0
- package/src/tools/schema.ts +119 -0
- package/src/tools/searchDocs.ts +345 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Pagination
|
|
3
|
+
description: Split large query results into pages with the metadata and URL helpers a UI needs.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Pagination
|
|
7
|
+
|
|
8
|
+
The [query builder](/docs/query-builder) and [ORM](/docs/orm/queries) share four
|
|
9
|
+
paginators, each a terminal method on any query. Each returns a result object
|
|
10
|
+
carrying the rows plus the metadata, cursors, and URL helpers a UI needs.
|
|
11
|
+
|
|
12
|
+
| Method | Strategy | Runs `COUNT`? | Best for |
|
|
13
|
+
| --------------------------------------- | ---------------------- | ------------- | --------------------------------------- |
|
|
14
|
+
| `paginate(perPage, page)` | Offset + total | Yes | Numbered page UIs (1, 2, 3, …) |
|
|
15
|
+
| `simplePaginate(perPage, page)` | Offset, next/prev only | No | "Previous / Next" without page numbers |
|
|
16
|
+
| `cursorPaginate({ cursor, limit })` | Keyset on `id` | No | Infinite scroll, very large tables |
|
|
17
|
+
| `keysetPaginate({ column, direction })` | Keyset on any column | No | Cursor paging on a sort other than `id` |
|
|
18
|
+
|
|
19
|
+
> **Note** — This is **data-layer** pagination. For Flow's reactive,
|
|
20
|
+
> server-driven paginated components, see
|
|
21
|
+
> [Flow › Pagination](/docs/flow/pagination).
|
|
22
|
+
|
|
23
|
+
## Getting Started
|
|
24
|
+
|
|
25
|
+
Pagination ships with `@zerotal/orm` as query-builder methods — nothing to
|
|
26
|
+
install or register. Call them at the end of any query:
|
|
27
|
+
|
|
28
|
+
```typescript
|
|
29
|
+
const page = await Post.query().latest().paginate();
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## paginate — full numbered pages
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
// in a controller
|
|
36
|
+
const page = await Post.query()
|
|
37
|
+
.where("status", "published")
|
|
38
|
+
.orderBy("created_at", "desc")
|
|
39
|
+
.paginate(15); // reads ?page= from the request
|
|
40
|
+
|
|
41
|
+
page.data; // Post[] for this page
|
|
42
|
+
page.total; // total matching rows
|
|
43
|
+
page.lastPage; // total number of pages
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
It runs a `COUNT` (ignoring limit/offset) then fetches the page, so you get a
|
|
47
|
+
complete picture — at the cost of the extra count query. `perPage` defaults to 15;
|
|
48
|
+
omit `page` and it reads the request's current page (`?page=`, or the page a Flow
|
|
49
|
+
component registered).
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
async paginate<T>(perPage?: number, page?: number): Promise<PaginateResult<T>>
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Result shape
|
|
56
|
+
|
|
57
|
+
The object you get back carries the rows under `data` plus everything a UI needs to
|
|
58
|
+
draw a pager — the current page, the total, the last page, and the 1-based `from`/`to`
|
|
59
|
+
indices for "showing 1–15 of 240" labels. The nested `meta` repeats the same numbers in
|
|
60
|
+
the shape API clients usually expect, so you can return it straight from a JSON
|
|
61
|
+
endpoint:
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
// type: PaginateResult<T>
|
|
65
|
+
interface PaginateResult<T> {
|
|
66
|
+
data: T[];
|
|
67
|
+
total: number;
|
|
68
|
+
page: number;
|
|
69
|
+
perPage: number;
|
|
70
|
+
lastPage: number;
|
|
71
|
+
from: number | null; // 1-based index of the first row (null if empty)
|
|
72
|
+
to: number | null; // 1-based index of the last row (null if empty)
|
|
73
|
+
meta: { from; to; currentPage; lastPage; perPage; total; path };
|
|
74
|
+
// URL + state helpers ↓
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### URL & state helpers
|
|
79
|
+
|
|
80
|
+
```typescript
|
|
81
|
+
// using a PaginateResult `page`
|
|
82
|
+
page.hasMorePages; // boolean
|
|
83
|
+
page.onFirstPage; // boolean
|
|
84
|
+
page.onLastPage; // boolean
|
|
85
|
+
|
|
86
|
+
page.nextPageUrl(); // '?page=2' | null on the last page
|
|
87
|
+
page.previousPageUrl(); // '?page=1' | null on the first page
|
|
88
|
+
page.url(3); // '?page=3'
|
|
89
|
+
page.url(3, "/posts", { q: "bun" }); // '/posts?q=bun&page=3'
|
|
90
|
+
|
|
91
|
+
// Render a numbered pager — extra query params are merged onto every link
|
|
92
|
+
page.links("/posts", { q: "bun" });
|
|
93
|
+
// → [{ page: 1, url: '/posts?q=bun&page=1', active: false }, …]
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`nextPageUrl`, `previousPageUrl`, `url`, and `links` all accept an optional base URL
|
|
97
|
+
and extra query params, which are preserved across links so filters survive
|
|
98
|
+
|
|
99
|
+
## simplePaginate — next / prev only
|
|
100
|
+
|
|
101
|
+
Skips the `COUNT` entirely by fetching `perPage + 1` rows to detect whether
|
|
102
|
+
another page follows. Use it when you don't need a total or page numbers:
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
// in a controller
|
|
106
|
+
const page = await Post.query().latest().simplePaginate(20);
|
|
107
|
+
|
|
108
|
+
page.data; // up to 20 rows
|
|
109
|
+
page.hasMorePages; // true if a further page exists
|
|
110
|
+
page.nextPageUrl();
|
|
111
|
+
page.onFirstPage;
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
async simplePaginate<T>(perPage?: number, page?: number): Promise<SimplePaginateResult<T>>
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
> **Note** — Argument order is `simplePaginate(perPage, page)` — **`perPage`
|
|
119
|
+
> first**, matching `paginate(perPage, page)`. `perPage` defaults to 15; omit
|
|
120
|
+
> `page` to read the request's current page.
|
|
121
|
+
|
|
122
|
+
There is no `total` or `lastPage` — that's the trade-off for dropping the count.
|
|
123
|
+
|
|
124
|
+
## cursorPaginate — keyset pagination on id
|
|
125
|
+
|
|
126
|
+
Cursor (keyset) pagination walks the table by `id` with `WHERE id > cursor ORDER
|
|
127
|
+
BY id ASC`, fetching `limit + 1` rows to detect a next page. It has **no
|
|
128
|
+
`COUNT`** and stays fast no matter how deep you scroll — ideal for
|
|
129
|
+
infinite-scroll feeds and very large tables:
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
// in a controller
|
|
133
|
+
let result = await Post.query().cursorPaginate({ limit: 20 });
|
|
134
|
+
|
|
135
|
+
result.data; // first 20 posts
|
|
136
|
+
result.nextCursor; // pass to the next call; null on the last page
|
|
137
|
+
result.prevCursor; // cursor that produced the previous page; null on the first
|
|
138
|
+
result.hasMore; // boolean
|
|
139
|
+
|
|
140
|
+
// Next page:
|
|
141
|
+
const more = await Post.query().cursorPaginate({ cursor: result.nextCursor!, limit: 20 });
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
async cursorPaginate<T>(options?: { cursor?: number; limit?: number }): Promise<CursorPaginateResult<T>>
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Defaults: `{ cursor: 0, limit: 15 }`. Because it orders by `id` ascending, the
|
|
149
|
+
cursor is stable even as rows are inserted. The trade-off vs. `paginate()` is no
|
|
150
|
+
random page access and no total count.
|
|
151
|
+
|
|
152
|
+
## keysetPaginate — keyset pagination on any column
|
|
153
|
+
|
|
154
|
+
`keysetPaginate()` generalises `cursorPaginate()`: it accepts **any sort
|
|
155
|
+
column**, supports `'asc'` and `'desc'`, and returns an **opaque base64 cursor**
|
|
156
|
+
that encodes the last row's sort value so clients cannot interpret or tamper with
|
|
157
|
+
it. A secondary `id ASC` tiebreaker keeps page boundaries stable when the sort
|
|
158
|
+
column isn't unique.
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
// in a controller
|
|
162
|
+
// First page, newest first
|
|
163
|
+
const p1 = await Post.query().keysetPaginate({ column: "created_at", direction: "desc" });
|
|
164
|
+
|
|
165
|
+
p1.data; // first page of rows
|
|
166
|
+
p1.nextCursor; // opaque base64 string | null on the last page
|
|
167
|
+
|
|
168
|
+
// Next page — pass the opaque cursor straight back
|
|
169
|
+
const p2 = await Post.query().keysetPaginate({
|
|
170
|
+
column: "created_at",
|
|
171
|
+
direction: "desc",
|
|
172
|
+
cursor: p1.nextCursor!,
|
|
173
|
+
});
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
async keysetPaginate<T>(options?: KeysetOptions): Promise<KeysetPaginateResult<T>>
|
|
178
|
+
|
|
179
|
+
interface KeysetOptions {
|
|
180
|
+
cursor?: string | null; // opaque cursor from the previous page; null = first page
|
|
181
|
+
column?: string; // sort column, must be a safe identifier (default 'id')
|
|
182
|
+
direction?: "asc" | "desc"; // default 'asc'
|
|
183
|
+
limit?: number; // default 15
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
> **Danger** — The `column` must be a safe SQL identifier; `keysetPaginate()`
|
|
188
|
+
> rejects anything else (e.g. `name; DROP TABLE users--`) by throwing. Never
|
|
189
|
+
> build the column name from raw, unvalidated user input.
|
|
190
|
+
|
|
191
|
+
## Choosing a paginator
|
|
192
|
+
|
|
193
|
+
- **Numbered admin tables / search results** → `paginate()` — you want page
|
|
194
|
+
numbers and a total.
|
|
195
|
+
- **Lightweight "Load more" / Prev-Next** → `simplePaginate()` — skip the count,
|
|
196
|
+
keep it cheap.
|
|
197
|
+
- **Infinite scroll keyed on `id`** → `cursorPaginate()` — constant-time paging,
|
|
198
|
+
no count, simplest cursor.
|
|
199
|
+
- **Cursor paging on a sort other than `id`** (e.g. `created_at`, `score`) →
|
|
200
|
+
`keysetPaginate()` — any column, opaque tamper-proof cursors.
|
|
201
|
+
|
|
202
|
+
All four work identically on a raw `DB.table(...)` query (returning plain rows)
|
|
203
|
+
and on a `Model.query()` (returning hydrated models with eager-loaded
|
|
204
|
+
relations).
|
|
205
|
+
|
|
206
|
+
## Putting it together
|
|
207
|
+
|
|
208
|
+
Two patterns cover most real use.
|
|
209
|
+
|
|
210
|
+
### A filtered, numbered results page
|
|
211
|
+
|
|
212
|
+
Paginate _after_ applying filters and sorting, then pass the current filters to
|
|
213
|
+
`links()` so they ride along on every page URL — without that, clicking "page 2" would
|
|
214
|
+
silently drop the user's search:
|
|
215
|
+
|
|
216
|
+
```typescript
|
|
217
|
+
// in a controller — GET /search?q=bun&page=2
|
|
218
|
+
const q = http.query("q", "");
|
|
219
|
+
|
|
220
|
+
const page = await Post.query()
|
|
221
|
+
.when(q, (query, term) => query.whereLike("title", `%${term}%`))
|
|
222
|
+
.latest()
|
|
223
|
+
.paginate(15, Number(http.query("page", "1")));
|
|
224
|
+
|
|
225
|
+
return view("search", {
|
|
226
|
+
results: page.data,
|
|
227
|
+
total: page.total,
|
|
228
|
+
// every link keeps ?q=… so the filter survives paging
|
|
229
|
+
pager: page.links("/search", { q }),
|
|
230
|
+
});
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### An infinite-scroll API endpoint
|
|
234
|
+
|
|
235
|
+
For a feed the client scrolls forever, return the rows plus the next cursor and nothing
|
|
236
|
+
else — no count, no page numbers. The client sends the cursor back to fetch more:
|
|
237
|
+
|
|
238
|
+
```typescript
|
|
239
|
+
// in a controller — GET /api/posts?cursor=128
|
|
240
|
+
const result = await Post.query()
|
|
241
|
+
.with("author")
|
|
242
|
+
.cursorPaginate({ cursor: Number(http.query("cursor", "0")), limit: 20 });
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
posts: result.data,
|
|
246
|
+
nextCursor: result.nextCursor, // null when there's nothing left to load
|
|
247
|
+
};
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
When the sort isn't `id` — a "newest first" feed, say — reach for `keysetPaginate()`
|
|
251
|
+
instead and hand the opaque `nextCursor` string back the same way.
|
|
252
|
+
|
|
253
|
+
## Testing
|
|
254
|
+
|
|
255
|
+
Set your suite up once as described in [Testing](/docs/testing). Pagination bugs
|
|
256
|
+
live at the edges, so test the boundaries rather than the happy page.
|
|
257
|
+
|
|
258
|
+
A `paginate()` result carries `data` plus the counts, so one call proves several
|
|
259
|
+
things at once:
|
|
260
|
+
|
|
261
|
+
```typescript
|
|
262
|
+
// tests/pagination/Posts.test.ts
|
|
263
|
+
import { test, expect } from "bun:test";
|
|
264
|
+
import { PostFactory } from "../../database/factories/PostFactory.ts";
|
|
265
|
+
import { Post } from "../../app/models/Post.ts";
|
|
266
|
+
|
|
267
|
+
test("reports the right totals on the last page", async () => {
|
|
268
|
+
await PostFactory.count(25).create();
|
|
269
|
+
|
|
270
|
+
const page = await Post.query().orderBy("id").paginate(10, 3); // 10 per page, page 3
|
|
271
|
+
|
|
272
|
+
expect(page.data).toHaveLength(5); // the remainder
|
|
273
|
+
expect(page.total).toBe(25);
|
|
274
|
+
expect(page.lastPage).toBe(3);
|
|
275
|
+
expect(page.from).toBe(21);
|
|
276
|
+
expect(page.to).toBe(25);
|
|
277
|
+
});
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
**The three cases worth pinning down** are the ones that produce a broken UI
|
|
281
|
+
rather than an exception:
|
|
282
|
+
|
|
283
|
+
```typescript
|
|
284
|
+
// tests/pagination/Posts.test.ts
|
|
285
|
+
test("an empty result reports null bounds, not zero", async () => {
|
|
286
|
+
const page = await Post.query().where("status", "nothing").paginate();
|
|
287
|
+
|
|
288
|
+
expect(page.data).toEqual([]);
|
|
289
|
+
expect(page.total).toBe(0);
|
|
290
|
+
expect(page.from).toBeNull(); // not 0 — a pager rendering "0–0 of 0" is a bug
|
|
291
|
+
expect(page.to).toBeNull();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("a page past the end is empty rather than an error", async () => {
|
|
295
|
+
await PostFactory.count(5).create();
|
|
296
|
+
|
|
297
|
+
const page = await Post.query().orderBy("id").paginate(10, 99);
|
|
298
|
+
|
|
299
|
+
expect(page.data).toEqual([]);
|
|
300
|
+
expect(page.page).toBe(99);
|
|
301
|
+
});
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
> **Warning** — Paginating without an `orderBy` gives the database licence to
|
|
305
|
+
> return rows in any order, so the same row can appear on two pages and another
|
|
306
|
+
> on none. A test that seeds five rows and reads one page will not catch it —
|
|
307
|
+
> order explicitly, and the bug never exists.
|
|
308
|
+
|
|
309
|
+
## References
|
|
310
|
+
|
|
311
|
+
### Query methods
|
|
312
|
+
|
|
313
|
+
| Method | Signature | Description |
|
|
314
|
+
| ---------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------- |
|
|
315
|
+
| `paginate` | `paginate(perPage?, page?): Promise<PaginateResult<T>>` | Offset pagination with a `COUNT` total and `lastPage`. |
|
|
316
|
+
| `simplePaginate` | `simplePaginate(perPage?, page?): Promise<SimplePaginateResult<T>>` | Offset next/prev pagination, no count. |
|
|
317
|
+
| `cursorPaginate` | `cursorPaginate({ cursor?, limit?, column? }): Promise<CursorPaginateResult<T>>` | Keyset pagination on `column` (default `id`, numeric cursor). |
|
|
318
|
+
| `keysetPaginate` | `keysetPaginate(options?): Promise<KeysetPaginateResult<T>>` | Keyset pagination on any column (opaque base64 cursor). |
|
|
319
|
+
|
|
320
|
+
### `PaginateResult<T>` helpers
|
|
321
|
+
|
|
322
|
+
| Member | Signature | Description |
|
|
323
|
+
| ----------------- | -------------------------------------------------- | ------------------------------------------------- |
|
|
324
|
+
| `hasMorePages` | `boolean` | True when a page follows the current one. |
|
|
325
|
+
| `onFirstPage` | `boolean` | True on the first page. |
|
|
326
|
+
| `onLastPage` | `(): boolean` | True on the last page. |
|
|
327
|
+
| `nextPageUrl` | `(baseUrl?, query?): string \| null` | URL of the next page, or `null` on the last. |
|
|
328
|
+
| `previousPageUrl` | `(baseUrl?, query?): string \| null` | URL of the previous page, or `null` on the first. |
|
|
329
|
+
| `elements` | `(each?): (number \| "...")[]` | Page-number window with `"..."` gaps for a pager. |
|
|
330
|
+
| `url` | `(page, baseUrl?, query?): string` | URL for any page number. |
|
|
331
|
+
| `links` | `(baseUrl?, query?): Array<{ page; url; active }>` | One link per page for rendering a numbered pager. |
|
|
332
|
+
|
|
333
|
+
> **Tip** — `SimplePaginateResult<T>` exposes the same URL helpers and
|
|
334
|
+
> `hasMorePages`, but has no `links()`, `onLastPage`, `total`, or `lastPage`.
|
|
335
|
+
|
|
336
|
+
## Next steps
|
|
337
|
+
|
|
338
|
+
- [Query Builder](/docs/query-builder) — building the query you paginate.
|
|
339
|
+
- [ORM Queries](/docs/orm/queries) — model queries, scopes, and eager loading.
|
|
340
|
+
- [Flow › Pagination](/docs/flow/pagination) — reactive paginated components.
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Password Reset
|
|
3
|
+
description: Generate, email, and verify hashed reset tokens so users can securely set a new password.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Password Reset
|
|
7
|
+
|
|
8
|
+
`PasswordBroker` handles the full password reset flow: generating and storing a
|
|
9
|
+
hashed token, emailing the reset link, verifying the token on submission, and
|
|
10
|
+
calling your password-update logic. It is database-agnostic — you supply the query
|
|
11
|
+
functions.
|
|
12
|
+
|
|
13
|
+
The broker never stores or emails the raw token. It generates a random plain token,
|
|
14
|
+
**SHA-256 hashes it** before handing it to `storeToken`, and emails the plain token
|
|
15
|
+
to the user. On `reset()` it re-hashes the submitted token and compares — so a leaked
|
|
16
|
+
database row can't be turned back into a working reset link.
|
|
17
|
+
|
|
18
|
+
> **Danger** — Store only the hashed token, exactly as the broker hands it to `storeToken`. Persisting the plain token would let anyone with database access forge a working reset link.
|
|
19
|
+
|
|
20
|
+
## Getting Started
|
|
21
|
+
|
|
22
|
+
`PasswordBroker` ships in the auth package:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# in your project root
|
|
26
|
+
bun add @zerotal/auth
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
There is no provider or `config/` file to register — you construct the broker
|
|
30
|
+
yourself and supply the query functions, so it works with any storage backend.
|
|
31
|
+
|
|
32
|
+
## Migration
|
|
33
|
+
|
|
34
|
+
The broker reads and writes through your query functions, so it needs a table to
|
|
35
|
+
back them. The default examples below use a `password_reset_tokens` table keyed by
|
|
36
|
+
email:
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
// database/migrations/xxxx_create_password_reset_tokens.ts
|
|
40
|
+
await Schema.create("password_reset_tokens", (table) => {
|
|
41
|
+
table.string("email").primary();
|
|
42
|
+
table.string("token");
|
|
43
|
+
table.timestamp("expires_at").nullable();
|
|
44
|
+
table.timestamp("created_at").nullable();
|
|
45
|
+
});
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
> **Note** — The broker decides validity from `createdAt + expireMinutes`, so the
|
|
49
|
+
> table must carry `created_at` and `findToken` must return it.
|
|
50
|
+
|
|
51
|
+
## Wire up the broker
|
|
52
|
+
|
|
53
|
+
Create the broker once and export it so controllers can import it. Pass an object
|
|
54
|
+
implementing `PasswordBrokerOptions` — each query function maps the broker onto your
|
|
55
|
+
storage:
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
// app/auth/passwords.ts
|
|
59
|
+
import { PasswordBroker, Hash } from "@zerotal/auth";
|
|
60
|
+
import { Notify } from "@zerotal/notifications";
|
|
61
|
+
import { DB } from "@zerotal/orm";
|
|
62
|
+
import { User } from "#app/models/User.ts";
|
|
63
|
+
import { PasswordResetNotification } from "#app/notifications/PasswordResetNotification.ts";
|
|
64
|
+
|
|
65
|
+
export const broker = new PasswordBroker({
|
|
66
|
+
expireMinutes: 60,
|
|
67
|
+
|
|
68
|
+
// The broker decides validity from `createdAt + expireMinutes`, so findToken
|
|
69
|
+
// MUST return createdAt and storeToken MUST persist created_at.
|
|
70
|
+
findToken: async (email) => {
|
|
71
|
+
const row = await DB.table("password_reset_tokens")
|
|
72
|
+
.where("email", email)
|
|
73
|
+
.first<{ token: string; created_at: string }>();
|
|
74
|
+
return row ? { token: row.token, createdAt: new Date(row.created_at) } : null;
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
storeToken: (email, hash, expiresAt) =>
|
|
78
|
+
DB.table("password_reset_tokens").upsert(
|
|
79
|
+
{ email },
|
|
80
|
+
{ token: hash, created_at: new Date(), expires_at: expiresAt },
|
|
81
|
+
),
|
|
82
|
+
|
|
83
|
+
deleteToken: (email) => DB.table("password_reset_tokens").where("email", email).delete(),
|
|
84
|
+
|
|
85
|
+
pruneTokens: (cutoff) =>
|
|
86
|
+
DB.table("password_reset_tokens").where("expires_at", "<", cutoff).delete(),
|
|
87
|
+
|
|
88
|
+
// PasswordResetNotification implements toMail(); { email } is the on-demand recipient.
|
|
89
|
+
sendResetLink: (email, token) =>
|
|
90
|
+
Notify.queue({ email }, new PasswordResetNotification(token, email)),
|
|
91
|
+
|
|
92
|
+
resetPassword: async (email, newPassword) => {
|
|
93
|
+
const user = await User.where("email", email).firstOrFail();
|
|
94
|
+
await user.fill({ password: await Hash.make(newPassword) }).save();
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
> **Warning** — `findToken` receives the email and `sendResetLink` receives the
|
|
100
|
+
> **plain** token; `storeToken` receives the **hashed** token. Don't swap them — the
|
|
101
|
+
> broker hashes the plain token before `storeToken` and re-hashes the submitted
|
|
102
|
+
> token on `reset()` to compare.
|
|
103
|
+
|
|
104
|
+
### The notification
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
// app/notifications/PasswordResetNotification.ts
|
|
108
|
+
import { Notification, MailMessage, type Notifiable } from "@zerotal/notifications";
|
|
109
|
+
|
|
110
|
+
export class PasswordResetNotification extends Notification {
|
|
111
|
+
constructor(
|
|
112
|
+
private readonly token: string,
|
|
113
|
+
private readonly email: string,
|
|
114
|
+
) {
|
|
115
|
+
super();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
channels(): string[] {
|
|
119
|
+
return ["mail"];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
toMail(_notifiable: Notifiable): MailMessage {
|
|
123
|
+
const url = `https://myapp.com/reset-password?token=${this.token}&email=${encodeURIComponent(this.email)}`;
|
|
124
|
+
return new MailMessage().subject("Reset your password").html(`
|
|
125
|
+
<p>Click the link below to reset your password. This link expires in 60 minutes.</p>
|
|
126
|
+
<p><a href="${url}">${url}</a></p>
|
|
127
|
+
<p>If you did not request a password reset, no action is needed.</p>
|
|
128
|
+
`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Controller
|
|
134
|
+
|
|
135
|
+
The controller wires the broker into request handlers: one pair for requesting a
|
|
136
|
+
link, one pair for submitting the new password. Compare the broker result against the
|
|
137
|
+
[`PASSWORDS` constants](#passwords-constants) rather than raw strings.
|
|
138
|
+
|
|
139
|
+
```typescript
|
|
140
|
+
// app/controllers/PasswordResetController.ts
|
|
141
|
+
import { broker } from "#app/auth/passwords.ts";
|
|
142
|
+
import { Auth, PASSWORDS } from "@zerotal/auth";
|
|
143
|
+
import { User } from "#app/models/User.ts";
|
|
144
|
+
import type { HttpContext } from "zerotal";
|
|
145
|
+
|
|
146
|
+
export class PasswordResetController {
|
|
147
|
+
showForm(ctx: HttpContext) {
|
|
148
|
+
ctx.view(ForgotPasswordPage({ errors: ctx.flashed("errors") }));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async sendLink(ctx: HttpContext) {
|
|
152
|
+
const { email } = await ctx.body<{ email: string }>();
|
|
153
|
+
await broker.sendResetLink(email);
|
|
154
|
+
// Same response regardless of whether the email exists — don't reveal account presence
|
|
155
|
+
ctx.flash("success", "If that address is registered, a reset link is on its way.");
|
|
156
|
+
ctx.redirect("/login", 303);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
showReset(ctx: HttpContext) {
|
|
160
|
+
ctx.view(
|
|
161
|
+
ResetPasswordPage({
|
|
162
|
+
token: ctx.query("token") ?? "",
|
|
163
|
+
email: ctx.query("email") ?? "",
|
|
164
|
+
errors: ctx.flashed("errors"),
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async reset(ctx: HttpContext) {
|
|
170
|
+
const { token, email, password } = await ctx.body<{
|
|
171
|
+
token: string;
|
|
172
|
+
email: string;
|
|
173
|
+
password: string;
|
|
174
|
+
}>();
|
|
175
|
+
|
|
176
|
+
const result = await broker.reset(token, email, password);
|
|
177
|
+
|
|
178
|
+
if (result === PASSWORDS.TOKEN) {
|
|
179
|
+
ctx.flash("errors", { token: ["This reset link is invalid or has expired."] });
|
|
180
|
+
ctx.redirect(`/reset-password?token=${token}&email=${encodeURIComponent(email)}`, 303);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Log the user in immediately after a successful reset
|
|
185
|
+
const user = await User.where("email", email).first();
|
|
186
|
+
if (user) {
|
|
187
|
+
ctx.session.regenerate();
|
|
188
|
+
await Auth.login(user);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
ctx.flash("success", "Your password has been reset.");
|
|
192
|
+
ctx.redirect("/dashboard", 303);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
> **Danger** — Always respond identically whether or not the email exists, as
|
|
198
|
+
> `sendLink` does above. Branching the response leaks which addresses have accounts.
|
|
199
|
+
|
|
200
|
+
## Routes
|
|
201
|
+
|
|
202
|
+
```typescript
|
|
203
|
+
// routes/web.ts
|
|
204
|
+
import { GuestMiddleware } from "@zerotal/auth";
|
|
205
|
+
import { PasswordResetController } from "#app/controllers/PasswordResetController.ts";
|
|
206
|
+
|
|
207
|
+
Router.get("/forgot-password", PasswordResetController, "showForm", [GuestMiddleware]);
|
|
208
|
+
Router.post("/forgot-password", PasswordResetController, "sendLink", [GuestMiddleware]);
|
|
209
|
+
Router.get("/reset-password", PasswordResetController, "showReset", [GuestMiddleware]);
|
|
210
|
+
Router.post("/reset-password", PasswordResetController, "reset", [GuestMiddleware]);
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
`GuestMiddleware` keeps already-authenticated users out of the reset flow.
|
|
214
|
+
|
|
215
|
+
> **Warning** — Add a [rate limiter](/docs/rate-limiting) to `POST /forgot-password` so the endpoint can't be used to spray reset emails or probe which addresses exist.
|
|
216
|
+
|
|
217
|
+
## Pruning expired tokens
|
|
218
|
+
|
|
219
|
+
Expired rows accumulate because `reset()` only deletes a token when it's used or
|
|
220
|
+
found expired on lookup. Call `broker.prune()` on a schedule to clear the rest — it
|
|
221
|
+
delegates to your `pruneTokens` function with the cutoff date:
|
|
222
|
+
|
|
223
|
+
```typescript
|
|
224
|
+
// app/schedules/PrunePasswordTokens.ts
|
|
225
|
+
import { Schedule } from "@zerotal/scheduler";
|
|
226
|
+
import { broker } from "#app/auth/passwords.ts";
|
|
227
|
+
|
|
228
|
+
export class PrunePasswordTokens extends Schedule {
|
|
229
|
+
cron = "0 * * * *"; // hourly
|
|
230
|
+
async handle() {
|
|
231
|
+
await broker.prune();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
See [Scheduler](/docs/scheduler) for the worker setup.
|
|
237
|
+
|
|
238
|
+
## PASSWORDS constants
|
|
239
|
+
|
|
240
|
+
Always compare against the constants rather than the raw strings — the values are
|
|
241
|
+
namespaced and may change.
|
|
242
|
+
|
|
243
|
+
| Constant | Value | Returned by |
|
|
244
|
+
| ----------------- | ------------------- | ------------------------------------------------------ |
|
|
245
|
+
| `PASSWORDS.SENT` | `'passwords.sent'` | `sendResetLink()` once the link is dispatched |
|
|
246
|
+
| `PASSWORDS.TOKEN` | `'passwords.token'` | `reset()` when the token is missing, wrong, or expired |
|
|
247
|
+
| `PASSWORDS.RESET` | `'passwords.reset'` | `reset()` after the password is updated |
|
|
248
|
+
|
|
249
|
+
## Testing
|
|
250
|
+
|
|
251
|
+
Set your suite up once as described in [Testing](/docs/testing) — everything
|
|
252
|
+
below assumes `createApp()` from your `tests/helpers.ts`.
|
|
253
|
+
|
|
254
|
+
The broker needs no application at all, because **your options own the
|
|
255
|
+
delivery**. `sendResetLink(email, token)` is a callback you wrote, so a test
|
|
256
|
+
captures the token instead of sending mail:
|
|
257
|
+
|
|
258
|
+
```typescript
|
|
259
|
+
// tests/auth/password-reset.test.ts
|
|
260
|
+
import { test, expect } from "bun:test";
|
|
261
|
+
import { PasswordBroker } from "@zerotal/auth";
|
|
262
|
+
|
|
263
|
+
test("a reset token changes the password, once", async () => {
|
|
264
|
+
const store = new Map<string, { token: string; createdAt: Date }>();
|
|
265
|
+
let sent: string | undefined;
|
|
266
|
+
let password: string | undefined;
|
|
267
|
+
|
|
268
|
+
const broker = new PasswordBroker({
|
|
269
|
+
findToken: async (email) => store.get(email) ?? null,
|
|
270
|
+
storeToken: async (email, hash) =>
|
|
271
|
+
void store.set(email, { token: hash, createdAt: new Date() }),
|
|
272
|
+
deleteToken: async (email) => void store.delete(email),
|
|
273
|
+
pruneTokens: async () => {},
|
|
274
|
+
sendResetLink: async (_email, token) => void (sent = token),
|
|
275
|
+
resetPassword: async (_email, next) => void (password = next),
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
expect(await broker.sendResetLink("jane@example.com")).toBe("passwords.sent");
|
|
279
|
+
|
|
280
|
+
expect(await broker.reset(sent!, "jane@example.com", "new-secret")).toBe("passwords.reset");
|
|
281
|
+
expect(password).toBe("new-secret");
|
|
282
|
+
|
|
283
|
+
// Single-use: replaying the same token must fail.
|
|
284
|
+
expect(await broker.reset(sent!, "jane@example.com", "other")).toBe("passwords.token");
|
|
285
|
+
});
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
**Every failure returns `"passwords.token"` rather than throwing**, so assert on
|
|
289
|
+
the return value — a `expect(...).toThrow()` here will never fire. Three cases
|
|
290
|
+
earn a test: a wrong token, an expired one, and the replay above.
|
|
291
|
+
|
|
292
|
+
On the HTTP side, the case worth pinning down is that a miss is indistinguishable
|
|
293
|
+
from a hit:
|
|
294
|
+
|
|
295
|
+
```typescript
|
|
296
|
+
// tests/http/password-reset.test.ts
|
|
297
|
+
const res = await app.post("/forgot-password", { email: "nobody@example.com" });
|
|
298
|
+
|
|
299
|
+
// Deliberately identical to the registered-address case — the response must not
|
|
300
|
+
// reveal whether the account exists.
|
|
301
|
+
res.assertRedirect("/forgot-password");
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
## References
|
|
305
|
+
|
|
306
|
+
The constructor takes `PasswordBrokerOptions`; `expireMinutes` is the only optional
|
|
307
|
+
field (default `60`), the rest are query functions you must supply.
|
|
308
|
+
|
|
309
|
+
### Constructor options
|
|
310
|
+
|
|
311
|
+
| Option | Signature | Description |
|
|
312
|
+
| ------------------------------------ | ------------------------------------------------------------------------ | ----------------------------------------------------------- |
|
|
313
|
+
| `expireMinutes` | `number` (optional, default `60`) | Minutes until a reset token expires. |
|
|
314
|
+
| `findToken(email)` | `(email: string) => Promise<{ token: string; createdAt: Date } \| null>` | Look up the stored hashed token and its creation time. |
|
|
315
|
+
| `storeToken(email, hash, expiresAt)` | `(email: string, hash: string, expiresAt: Date) => Promise<void>` | Persist the **hashed** token and its expiry. |
|
|
316
|
+
| `deleteToken(email)` | `(email: string) => Promise<void>` | Remove a single user's token after use or expiry. |
|
|
317
|
+
| `pruneTokens(cutoff)` | `(cutoff: Date) => Promise<void>` | Delete all tokens created before `cutoff`. |
|
|
318
|
+
| `sendResetLink(email, token)` | `(email: string, token: string) => Promise<void>` | Deliver the **plain** token to the user (usually via mail). |
|
|
319
|
+
| `resetPassword(email, newPassword)` | `(email: string, newPassword: string) => Promise<void>` | Apply the new password to the user record. |
|
|
320
|
+
|
|
321
|
+
### Broker methods
|
|
322
|
+
|
|
323
|
+
| Method | Signature | Description |
|
|
324
|
+
| ------------------------------- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
|
|
325
|
+
| `sendResetLink(email)` | `(email: string) => Promise<'passwords.sent'>` | Generate + store a hashed token and send the link. |
|
|
326
|
+
| `reset(token, email, password)` | `(token: string, email: string, password: string) => Promise<'passwords.token' \| 'passwords.reset'>` | Verify the token and update the password. |
|
|
327
|
+
| `prune()` | `() => Promise<void>` | Delete tokens older than `expireMinutes`. |
|
|
328
|
+
|
|
329
|
+
## Next steps
|
|
330
|
+
|
|
331
|
+
- [Encryption & Hashing](/docs/encryption) — the `Hash` facade used in `resetPassword`.
|
|
332
|
+
- [Authentication](/docs/authentication) — logging the user in after a reset.
|
|
333
|
+
- [Notifications](/docs/notifications) — building and queueing the `PasswordResetNotification`.
|
|
334
|
+
- [Rate Limiting](/docs/rate-limiting) — throttle the forgot-password endpoint.
|
|
335
|
+
- [Scheduler](/docs/scheduler) — run `prune()` on a recurring schedule.
|