@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,402 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Flow Pagination
|
|
3
|
+
description: The Pagination mixin, URL-synced pages, and named paginators.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Pagination
|
|
7
|
+
|
|
8
|
+
Paginate in the database with `Model.paginate(perPage)` — it returns the page the request is on, so the component holds the result and nothing else. Compose the `Pagination` mixin (`Component.using(Pagination)`) for the page state and navigation actions. The standalone `paginate()` helper is for arrays you already hold in memory.
|
|
9
|
+
|
|
10
|
+
## In-memory pagination
|
|
11
|
+
|
|
12
|
+
For arrays already held in memory, `paginate(items, page, perPage)` slices the data and returns a rich paginator object with metadata and a windowed page list:
|
|
13
|
+
|
|
14
|
+
```typescript
|
|
15
|
+
import { paginate } from "@zerotal/flow";
|
|
16
|
+
|
|
17
|
+
export class PostsPage extends Component {
|
|
18
|
+
@url page = 1;
|
|
19
|
+
@locked all: Post[] = [];
|
|
20
|
+
|
|
21
|
+
override async onMount() {
|
|
22
|
+
this.all = await Post.query().orderBy("created_at", "desc").get();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@expose goTo(n: number): void {
|
|
26
|
+
this.page = n;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
override async render() {
|
|
30
|
+
const p = paginate(this.all, this.page, 10);
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div>
|
|
34
|
+
<ul>
|
|
35
|
+
{p.data.map((post) => (
|
|
36
|
+
<li key={String(post.id)}>{post.title}</li>
|
|
37
|
+
))}
|
|
38
|
+
</ul>
|
|
39
|
+
|
|
40
|
+
<div class="flex items-center gap-2 mt-4 text-sm">
|
|
41
|
+
<span class="text-gray-500">
|
|
42
|
+
Showing {p.from}–{p.to} of {p.total}
|
|
43
|
+
</span>
|
|
44
|
+
|
|
45
|
+
<nav class="flex gap-1 ml-auto">
|
|
46
|
+
{p.elements().map((el) =>
|
|
47
|
+
el === "..." ? (
|
|
48
|
+
<span class="px-2 py-1 text-gray-400">…</span>
|
|
49
|
+
) : (
|
|
50
|
+
<button
|
|
51
|
+
key={String(el)}
|
|
52
|
+
onClick={() => this.goTo(el as number)}
|
|
53
|
+
class={
|
|
54
|
+
el === p.page
|
|
55
|
+
? "px-3 py-1 rounded bg-orange-500 text-white font-semibold"
|
|
56
|
+
: "px-3 py-1 rounded hover:bg-gray-100"
|
|
57
|
+
}
|
|
58
|
+
>
|
|
59
|
+
{el}
|
|
60
|
+
</button>
|
|
61
|
+
),
|
|
62
|
+
)}
|
|
63
|
+
</nav>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Paginator properties
|
|
72
|
+
|
|
73
|
+
| Property | Type | Description |
|
|
74
|
+
| ----------------- | --------------------- | ------------------------------------------------- |
|
|
75
|
+
| `data` | `T[]` | Items on the current page |
|
|
76
|
+
| `total` | `number` | Total item count across all pages |
|
|
77
|
+
| `page` | `number` | Current page number (1-based) |
|
|
78
|
+
| `perPage` | `number` | Items per page |
|
|
79
|
+
| `lastPage` | `number` | Number of the last page |
|
|
80
|
+
| `from` | `number` | 1-based index of the first item on this page |
|
|
81
|
+
| `to` | `number` | 1-based index of the last item on this page |
|
|
82
|
+
| `onFirstPage` | `boolean` | `true` if `page === 1` |
|
|
83
|
+
| `hasMorePages` | `boolean` | `true` if there are pages after the current one |
|
|
84
|
+
| `elements(each?)` | `(number \| "...")[]` | Windowed page list with ellipsis for large ranges |
|
|
85
|
+
|
|
86
|
+
`elements()` produces a compact list like `[1, 2, "...", 8, 9, 10]` — always showing the first page, last page, and a window around the current page. Pass a window size to `elements(window)` to control how many adjacent pages are shown on each side of the current one (default: 1).
|
|
87
|
+
|
|
88
|
+
## The Pagination mixin
|
|
89
|
+
|
|
90
|
+
`Pagination` is a class mixin that adds page state, URL sync, and navigation methods automatically. Compose it with [`Component.using(...)`](/docs/flow/layouts#composing-behaviour-with-mixins).
|
|
91
|
+
|
|
92
|
+
```tsx
|
|
93
|
+
import { Component, Pagination, Pager } from "@zerotal/flow";
|
|
94
|
+
|
|
95
|
+
export class PostsPage extends Component.using(Pagination) {
|
|
96
|
+
override async render() {
|
|
97
|
+
const posts = await Post.paginate(10); // uses this component's page
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div>
|
|
101
|
+
<ul>
|
|
102
|
+
{posts.data.map((post) => (
|
|
103
|
+
<li key={String(post.id)}>{post.title}</li>
|
|
104
|
+
))}
|
|
105
|
+
</ul>
|
|
106
|
+
|
|
107
|
+
<Pager paginator={posts} />
|
|
108
|
+
|
|
109
|
+
<p class="text-sm text-gray-500 mt-2">
|
|
110
|
+
Showing {posts.from}–{posts.to} of {posts.total}
|
|
111
|
+
</p>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`<Pager>` renders the Prev / numbered / Next links and takes either paginator — the ORM's result or the in-memory one.
|
|
119
|
+
|
|
120
|
+
### Pagination members
|
|
121
|
+
|
|
122
|
+
Every navigation method accepts an optional `pageName` (default `"page"`) so one component can drive several **independent** paginators — `this.nextPage("invoices")` alongside `Invoice.paginate(10, undefined, "invoices")`. The default paginator is URL-synced (`?page=`); named paginators live in the snapshot.
|
|
123
|
+
|
|
124
|
+
| Member | Type | Description |
|
|
125
|
+
| ------------------------- | ---------------- | --------------------------------------------------------- |
|
|
126
|
+
| `page` | `@url number` | Current page of the default paginator, synced to `?page=` |
|
|
127
|
+
| `paginators` | `@expose record` | Current page of each named paginator, keyed by name |
|
|
128
|
+
| `gotoPage(n, pageName?)` | `@expose method` | Jump to a specific page |
|
|
129
|
+
| `resetPage(pageName?)` | `@expose method` | Reset to page 1 (call when filters change) |
|
|
130
|
+
| `nextPage(pageName?)` | `@expose method` | Advance to the next page |
|
|
131
|
+
| `previousPage(pageName?)` | `@expose method` | Go back to the previous page |
|
|
132
|
+
| `pageFor(pageName?)` | method | Read the current page of a paginator |
|
|
133
|
+
|
|
134
|
+
Optional update hooks fire around a page change: define any of `updatingPage(page, name)` / `updatedPage(page, name)` (default paginator) or the generic `updatingPaginators(page, name)` / `updatedPaginators(page, name)`.
|
|
135
|
+
|
|
136
|
+
### Resetting page on filter change
|
|
137
|
+
|
|
138
|
+
Always call `this.resetPage()` when a filter changes — otherwise the current page may exceed the new total and return an empty result set:
|
|
139
|
+
|
|
140
|
+
```typescript
|
|
141
|
+
@url search = "";
|
|
142
|
+
@url status = "all";
|
|
143
|
+
|
|
144
|
+
@expose async applySearch(q: string): Promise<void> {
|
|
145
|
+
this.search = q;
|
|
146
|
+
this.resetPage();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
@expose async setStatus(s: string): Promise<void> {
|
|
150
|
+
this.status = s;
|
|
151
|
+
this.resetPage();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
override async render() {
|
|
155
|
+
const posts = await Post.query()
|
|
156
|
+
.when(this.search, (q) => q.where("title", "like", `%${this.search}%`))
|
|
157
|
+
.when(this.status !== "all", (q) => q.where("status", this.status))
|
|
158
|
+
.paginate(15);
|
|
159
|
+
// …
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Composing mixins
|
|
164
|
+
|
|
165
|
+
`Pagination` composes cleanly with other mixins via `Component.using(...)`:
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
// Sorting + pagination — `Sorting` is your own mixin, `Pagination` is shipped:
|
|
169
|
+
export class PostsPage extends Component.using(Sorting, Pagination) {
|
|
170
|
+
// has this.page and the nav actions, plus whatever your Sorting mixin adds
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// A per-page preference of your own — pass it to the query:
|
|
174
|
+
export class PostsPage extends Component.using(Pagination) {
|
|
175
|
+
readonly perPage = 25;
|
|
176
|
+
|
|
177
|
+
override async render() {
|
|
178
|
+
const posts = await Post.paginate(this.perPage);
|
|
179
|
+
// …
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Database pagination
|
|
185
|
+
|
|
186
|
+
For large datasets, avoid loading all rows in `onMount()`. Paginate in the database instead.
|
|
187
|
+
|
|
188
|
+
`Model.paginate(perPage)` returns the page the request is on. Compose the mixin, query in `render()`, and there is no page to pass, no state to hold, and nothing to refresh — a page change re-renders, and the re-render re-queries:
|
|
189
|
+
|
|
190
|
+
```tsx
|
|
191
|
+
export class PostsPage extends Component.using(Pagination) {
|
|
192
|
+
override async render() {
|
|
193
|
+
const posts = await Post.paginate(10); // this component's page
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<div>
|
|
197
|
+
<ul>
|
|
198
|
+
{posts.data.map((p) => (
|
|
199
|
+
<li key={String(p.id)}>{p.title}</li>
|
|
200
|
+
))}
|
|
201
|
+
</ul>
|
|
202
|
+
|
|
203
|
+
<nav>
|
|
204
|
+
<button onClick={this.previousPage} disabled={posts.page <= 1}>
|
|
205
|
+
‹ Prev
|
|
206
|
+
</button>
|
|
207
|
+
<span>
|
|
208
|
+
Page {posts.page} of {posts.lastPage}
|
|
209
|
+
</span>
|
|
210
|
+
<button onClick={this.nextPage} disabled={posts.page >= posts.lastPage}>
|
|
211
|
+
Next ›
|
|
212
|
+
</button>
|
|
213
|
+
</nav>
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
That is the whole component. `render()` is `async`, so the query runs there and re-runs on every round-trip, with the trade-off that implies: one query per render. Hold the result in a `@locked` field and load it in `onMount()` instead when the query is expensive and you'd rather re-run it only on demand (`this.refresh()`).
|
|
221
|
+
|
|
222
|
+
Outside a component, `paginate()` reads `?page=` from the query string — what a controller wants. The mixin points it at the component's own page instead, which is what makes it work over WebSocket, where there is no URL to read.
|
|
223
|
+
|
|
224
|
+
Pass the page explicitly when it isn't the request's — a report job, a fixed first page, a second paginator driven by something other than the mixin:
|
|
225
|
+
|
|
226
|
+
```typescript
|
|
227
|
+
const first = await Post.paginate(10, 1); // always page 1
|
|
228
|
+
const invoices = await Invoice.paginate(10, undefined, "invoices"); // a named paginator
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
The query builder takes the same arguments when you need to build the query up first. Keep the **result** on the component — it already carries the page, the total, the last page, and the URL helpers, so there is nothing to copy out of it:
|
|
232
|
+
|
|
233
|
+
```typescript
|
|
234
|
+
export class PostsPage extends Component.using(Pagination) {
|
|
235
|
+
@url search = "";
|
|
236
|
+
@url status = "all";
|
|
237
|
+
|
|
238
|
+
@locked posts!: PaginateResult<Post>;
|
|
239
|
+
|
|
240
|
+
override async onMount() {
|
|
241
|
+
this.posts = await Post.query()
|
|
242
|
+
.where("status", "!=", "deleted")
|
|
243
|
+
.when(this.status !== "all", (q) => q.where("status", this.status))
|
|
244
|
+
.when(this.search, (q) => q.where("title", "like", `%${this.search}%`))
|
|
245
|
+
.orderBy("created_at", "desc")
|
|
246
|
+
.paginate(15); // the component's page
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
@expose async applySearch(q: string): Promise<void> {
|
|
250
|
+
this.search = q;
|
|
251
|
+
this.resetPage(); // filters changed — back to page 1
|
|
252
|
+
this.refresh(); // re-run onMount on this round-trip
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
override async render() {
|
|
256
|
+
return (
|
|
257
|
+
<div>
|
|
258
|
+
<input value={this.search} live placeholder="Search posts…" class="input" />
|
|
259
|
+
|
|
260
|
+
<ul class="mt-4 space-y-2">
|
|
261
|
+
{this.posts.data.map((p) => (
|
|
262
|
+
<li key={String(p.id)} class="border rounded p-3">
|
|
263
|
+
<h3 class="font-semibold">{p.title}</h3>
|
|
264
|
+
</li>
|
|
265
|
+
))}
|
|
266
|
+
</ul>
|
|
267
|
+
|
|
268
|
+
<nav class="flex items-center gap-1 mt-4">
|
|
269
|
+
<button onClick={this.previousPage} disabled={this.posts.page <= 1}>
|
|
270
|
+
‹ Prev
|
|
271
|
+
</button>
|
|
272
|
+
<span class="px-3">
|
|
273
|
+
Page {this.posts.page} of {this.posts.lastPage}
|
|
274
|
+
</span>
|
|
275
|
+
<button onClick={this.nextPage} disabled={this.posts.page >= this.posts.lastPage}>
|
|
276
|
+
Next ›
|
|
277
|
+
</button>
|
|
278
|
+
</nav>
|
|
279
|
+
<p class="text-sm text-gray-500 mt-1">{this.posts.total} total posts</p>
|
|
280
|
+
</div>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
> **Warning** — Don't mirror the paginator onto the component. Fields like `@locked total`, `@locked lastPage`, `@locked perPage`, and a hand-rolled `@url page` restate what `PaginateResult` already holds, and each one is a value that can drift out of step with the query. The mixin owns the page; the result owns everything else.
|
|
287
|
+
|
|
288
|
+
The ORM's `.paginate()` issues two queries — a `COUNT(*)` for the total and a `LIMIT/OFFSET` for the data — and returns `{ data, total, page, perPage, lastPage, from, to, meta }` plus `nextPageUrl()` / `prevPageUrl()` / `urlForPage()` for building links.
|
|
289
|
+
|
|
290
|
+
## Infinite scroll
|
|
291
|
+
|
|
292
|
+
Use the `<InfiniteScroll>` component to load more pages as the user scrolls down, without explicit page navigation:
|
|
293
|
+
|
|
294
|
+
```tsx
|
|
295
|
+
import { InfiniteScroll } from "@zerotal/flow";
|
|
296
|
+
|
|
297
|
+
export class FeedPage extends Component {
|
|
298
|
+
@expose page = 1;
|
|
299
|
+
@locked posts: Post[] = [];
|
|
300
|
+
@locked hasMore = true;
|
|
301
|
+
|
|
302
|
+
override async onMount() {
|
|
303
|
+
const result = await Post.query().orderBy("created_at", "desc").paginate(20, 1);
|
|
304
|
+
this.posts = result.data;
|
|
305
|
+
this.hasMore = result.hasMorePages;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
@expose async loadMore(): Promise<void> {
|
|
309
|
+
this.page++;
|
|
310
|
+
const result = await Post.query().orderBy("created_at", "desc").paginate(20, this.page);
|
|
311
|
+
|
|
312
|
+
this.posts = [...this.posts, ...result.data]; // append
|
|
313
|
+
this.hasMore = result.hasMorePages;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
override async render() {
|
|
317
|
+
return (
|
|
318
|
+
<div>
|
|
319
|
+
<ul class="space-y-4">
|
|
320
|
+
{this.posts.map((p) => (
|
|
321
|
+
<li key={String(p.id)} class="border rounded p-4">
|
|
322
|
+
{p.title}
|
|
323
|
+
</li>
|
|
324
|
+
))}
|
|
325
|
+
</ul>
|
|
326
|
+
|
|
327
|
+
<InfiniteScroll show={this.hasMore} onMore={this.loadMore}>
|
|
328
|
+
<div class="h-12 flex items-center justify-center text-gray-400 text-sm">
|
|
329
|
+
Loading more…
|
|
330
|
+
</div>
|
|
331
|
+
</InfiniteScroll>
|
|
332
|
+
|
|
333
|
+
{!this.hasMore && (
|
|
334
|
+
<p class="text-center text-gray-400 text-sm mt-4">You've reached the end.</p>
|
|
335
|
+
)}
|
|
336
|
+
</div>
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
`<InfiniteScroll>` calls the `onMore` action when its sentinel enters the viewport (using an IntersectionObserver). Pass `show={this.hasMore}` to stop rendering the sentinel — and stop loading — once you reach the end.
|
|
343
|
+
|
|
344
|
+
## Cursor pagination
|
|
345
|
+
|
|
346
|
+
For very large tables where `OFFSET` pagination is slow, use cursor-based pagination via the ORM. The cursor encodes the last-seen row's sort key and is more efficient for deep pages:
|
|
347
|
+
|
|
348
|
+
```typescript
|
|
349
|
+
export class ActivityPage extends Component {
|
|
350
|
+
@url cursor: string | null = null;
|
|
351
|
+
@locked items: Activity[] = [];
|
|
352
|
+
@locked nextCursor: string | null = null;
|
|
353
|
+
@locked prevCursor: string | null = null;
|
|
354
|
+
|
|
355
|
+
private async load() {
|
|
356
|
+
const result = await Activity.query()
|
|
357
|
+
.orderBy("id", "desc")
|
|
358
|
+
.cursorPaginate(20, this.cursor);
|
|
359
|
+
|
|
360
|
+
this.items = result.data;
|
|
361
|
+
this.nextCursor = result.nextCursor;
|
|
362
|
+
this.prevCursor = result.prevCursor;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
override async onMount() {
|
|
366
|
+
await this.load();
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
@expose async next(): Promise<void> {
|
|
370
|
+
this.cursor = this.nextCursor;
|
|
371
|
+
await this.load();
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
@expose async prev(): Promise<void> {
|
|
375
|
+
this.cursor = this.prevCursor;
|
|
376
|
+
await this.load();
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
override async render() {
|
|
380
|
+
return (
|
|
381
|
+
<div>
|
|
382
|
+
<ul>
|
|
383
|
+
{this.items.map((a) => (
|
|
384
|
+
<li key={String(a.id)}>{a.description}</li>
|
|
385
|
+
))}
|
|
386
|
+
</ul>
|
|
387
|
+
<div class="flex gap-4 mt-4">
|
|
388
|
+
<button onClick={this.prev} disabled={!this.prevCursor}>← Previous</button>
|
|
389
|
+
<button onClick={this.next} disabled={!this.nextCursor}>Next →</button>
|
|
390
|
+
</div>
|
|
391
|
+
</div>
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
Cursor pagination works best when you paginate by a monotonic column (`id`, `created_at`) in a consistent direction. It does not support random page access.
|
|
398
|
+
|
|
399
|
+
## Next steps
|
|
400
|
+
|
|
401
|
+
- [Flow overview](/docs/flow) — the guide's front page and the rest of the sections.
|
|
402
|
+
- [Reference](/docs/flow/references) — every decorator, prop, and directive in one table.
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Flow Transport & Performance
|
|
3
|
+
description: How updates reach the browser, what to do on hostile networks, and the polish that hides latency.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Transport & performance
|
|
7
|
+
|
|
8
|
+
Every WebSocket action returns a **patch**. To keep those patches small, Flow sends only what changed:
|
|
9
|
+
|
|
10
|
+
- **Snapshot deltas.** Instead of re-sending the whole snapshot each round-trip, Flow transmits only the properties that changed (plus any that were removed) and the new signed checksum. The client rebuilds the full snapshot from the copy it already holds. This is exact — the server diffs against the client's own snapshot — and still HMAC-verified, so a large `@locked` collection that doesn't change on a given action isn't re-sent on every keystroke.
|
|
11
|
+
- **HTML suppression.** When an action re-renders to markup byte-identical to
|
|
12
|
+
the last patch on that connection, the server omits the HTML entirely and the
|
|
13
|
+
client keeps its DOM — no morph at all. This covers a save that only flashed a
|
|
14
|
+
toast, or a change reflected purely through client-reactive bindings like
|
|
15
|
+
`:class` and `flow:text`.
|
|
16
|
+
|
|
17
|
+
Both are automatic; there is nothing to configure. What you can still do to help:
|
|
18
|
+
|
|
19
|
+
- Put display-only collections behind `@transient` (or store IDs and re-load inside the action that needs them) so they never enter the snapshot in the first place.
|
|
20
|
+
- Prefer `@computed` for values derivable from other state.
|
|
21
|
+
- Reach for client expressions / reactive bindings (`onClick={() => this.open = true}`, `class={…}`) for pure-UI state so those interactions don't round-trip at all.
|
|
22
|
+
|
|
23
|
+
Set `ZT_FLOW_TRANSPORT_LOG=1` in development to log each patch's delta size versus the full snapshot size (and whether the HTML was suppressed), so you can see the payload of a given interaction while you build.
|
|
24
|
+
|
|
25
|
+
## WebSocket-blocked networks (HTTP fallback)
|
|
26
|
+
|
|
27
|
+
Some strict corporate proxies and firewalls block WebSocket upgrades outright — which would otherwise lock those users out of a socket-driven app. Flow has an automatic **circuit breaker**: after a few failed handshakes the client stops waiting on the socket and starts sending action frames over a plain **HTTP POST** to `/__flow/http`. That endpoint runs the _exact same_ server pipeline — hydrate → dispatch →
|
|
28
|
+
render → patch — and returns the frames for the client to apply. Actions,
|
|
29
|
+
validation, flashes, redirects, and events all keep working, with no code change
|
|
30
|
+
in your components.
|
|
31
|
+
|
|
32
|
+
It's a graceful degrade, not a mode you configure. WebSocket reconnection keeps running in the background, so the moment the socket becomes reachable again the client upgrades back to it automatically. The trade-offs while in fallback:
|
|
33
|
+
|
|
34
|
+
- Each action is a request/response, with no server-pushed frames — so `@task`
|
|
35
|
+
streaming arrives as one batched update rather than token by token.
|
|
36
|
+
- Real-time `@on("echo:…")`, `@presence`, and `@shared` broadcasts are not
|
|
37
|
+
delivered, because those ride the separate broadcasting socket.
|
|
38
|
+
|
|
39
|
+
Everything driven by your own actions still works. Nothing is sent over HTTP until the socket has actually failed; the happy path is unchanged.
|
|
40
|
+
|
|
41
|
+
## Interaction polish
|
|
42
|
+
|
|
43
|
+
The perceived speed of a server-driven app comes from three things: never showing
|
|
44
|
+
a blank box, never making a click wait to feel like it did something, and never
|
|
45
|
+
paying for a navigation you could have paid for during the hover. Flow has a small primitive for each.
|
|
46
|
+
|
|
47
|
+
### Skeletons
|
|
48
|
+
|
|
49
|
+
`<Skeleton>` is a pulsing placeholder block — pure markup plus a bundled animation, so it needs no app CSS. Use it for the shape of content that hasn't loaded yet:
|
|
50
|
+
|
|
51
|
+
```tsx
|
|
52
|
+
import { Skeleton } from "@zerotal/flow";
|
|
53
|
+
|
|
54
|
+
<Skeleton height="1.5rem" width="60%" /> {/* one bar */}
|
|
55
|
+
<Skeleton lines={3} /> {/* three stacked lines, last one short */}
|
|
56
|
+
<Skeleton height="10rem" rounded="0.75rem" /> {/* a card-sized block */}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The most useful place is a lazy child's `placeholder()` — what shows while the component mounts on viewport entry — and inside a `<Loading>` region (`<Loading skeleton />` renders one for you):
|
|
60
|
+
|
|
61
|
+
```tsx
|
|
62
|
+
export class ChartWidget extends Component {
|
|
63
|
+
override placeholder() {
|
|
64
|
+
return <Skeleton height="12rem" rounded="0.75rem" />;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Parent — the skeleton shows until the widget loads:
|
|
69
|
+
<ChartWidget lazy />;
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Tune the tone with the `--flow-skeleton-color` CSS variable; the pulse respects `prefers-reduced-motion`.
|
|
73
|
+
|
|
74
|
+
### Optimistic UI with automatic rollback
|
|
75
|
+
|
|
76
|
+
Because state is a server-authoritative snapshot, optimistic UI and its rollback are already built in. A client expression that changes a prop and then calls an action applies the change **instantly**; when the server responds, the authoritative snapshot reconciles the prop — so if the server **rejects** the change, it snaps back on its own. There is no separate optimistic library and no compensation code to write:
|
|
77
|
+
|
|
78
|
+
```tsx
|
|
79
|
+
@expose liked = false;
|
|
80
|
+
@expose confirmLike(): void { /* … persist … */ }
|
|
81
|
+
|
|
82
|
+
override async render() {
|
|
83
|
+
return (
|
|
84
|
+
<button onClick={() => { this.liked = true; $flow.call("confirmLike"); }}>
|
|
85
|
+
♥ Like
|
|
86
|
+
<span show={this.liked} class="ml-1 text-rose-600">— saving…</span>
|
|
87
|
+
<span showOnError class="ml-1 text-red-600">— couldn't save</span>
|
|
88
|
+
</button>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The `showOnError` (and `hideOnError`) directive reveals an element after an action **fails** — an unhandled throw in the action, or an `onUpdating` hook that rejected the optimistic write. It's the failed-state counterpart to `showOnLoading`, and it clears automatically when the next action for that component is dispatched. Rollback itself needs nothing extra: the reconciliation that keeps the client honest also reverts a rejected optimistic value. (Validation errors are not "failures" in this sense — they populate the error bag and drive `error={this.errors.field}` instead.)
|
|
94
|
+
|
|
95
|
+
### Reactive lists & optimistic collections
|
|
96
|
+
|
|
97
|
+
Optimistic UI for a scalar is easy (flip a bool, reconcile on the patch). A **list** add/remove is the hard case in server-driven UIs: `{this.items.map(…)}` renders to static server HTML, so pushing to the array client-side wouldn't show until the round-trip. Two pieces solve it.
|
|
98
|
+
|
|
99
|
+
**`<For>` — a reactive list.** Render the list with `<For>` and it compiles to an Alpine `x-for`, so any client change to the array re-renders it instantly (server patches still keep it authoritative):
|
|
100
|
+
|
|
101
|
+
```tsx
|
|
102
|
+
import { For } from "@zerotal/flow";
|
|
103
|
+
|
|
104
|
+
<For each={this.todos} keyBy="id">
|
|
105
|
+
{(todo) => (
|
|
106
|
+
<li class={todo.done ? "line-through" : ""}>
|
|
107
|
+
{todo.text}
|
|
108
|
+
<button onClick={() => $flow.call("removeTodo", todo.id)}>×</button>
|
|
109
|
+
</li>
|
|
110
|
+
)}
|
|
111
|
+
</For>;
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The item template supports element structure, static attributes, `class`/`className` (→ reactive `:class`), reactive attributes, `on*` arrow handlers (→ Alpine `@event`, with `this.` resolving to the component), and `{item.field}` text. Anything more exotic → a clear compile error pointing you at a raw Alpine `<template x-for="item in $flow.todos">` escape hatch.
|
|
115
|
+
|
|
116
|
+
**`appendOptimistic` / `removeOptimistic` — instant add/remove with rollback.** Mutate the array optimistically, then dispatch the action that persists it:
|
|
117
|
+
|
|
118
|
+
```tsx
|
|
119
|
+
<button
|
|
120
|
+
onClick={() => {
|
|
121
|
+
$flow.appendOptimistic("todos", { id: `tmp-${Date.now()}`, text: this.draft });
|
|
122
|
+
$flow.call("addTodo"); // persists + reloads todos
|
|
123
|
+
}}
|
|
124
|
+
>
|
|
125
|
+
Add
|
|
126
|
+
</button>
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The item appears the instant you click. Two things then happen to it.
|
|
130
|
+
|
|
131
|
+
**It survives interim patches.** A broadcast or event landing mid-flight
|
|
132
|
+
re-applies your pending change on top of the server state.
|
|
133
|
+
|
|
134
|
+
**It is reconciled when the owning action's patch lands.** On success the
|
|
135
|
+
authoritative server list stands — your persisted row, with its real id. On
|
|
136
|
+
failure the server list is unchanged, so the optimistic change rolls back on its
|
|
137
|
+
own. Pair it with `showOnError` for a failed-state hint.
|
|
138
|
+
|
|
139
|
+
Persist the item in that action — push to the `@expose` array, or reload it — so
|
|
140
|
+
it stays after reconciliation. `removeOptimistic(prop, (item) => …)` is the
|
|
141
|
+
mirror image for deletes.
|
|
142
|
+
|
|
143
|
+
### Hover-prefetch
|
|
144
|
+
|
|
145
|
+
Add `hover` to a `navigate` link and Flow fetches the target after a brief hover dwell (and on first touch on mobile), caching the HTML so the click swaps instantly instead of waiting on a cold request:
|
|
146
|
+
|
|
147
|
+
```tsx
|
|
148
|
+
<a href="/posts" navigate hover>
|
|
149
|
+
Posts
|
|
150
|
+
</a>;
|
|
151
|
+
{
|
|
152
|
+
/* or */
|
|
153
|
+
}
|
|
154
|
+
<Link href="/posts" hover>
|
|
155
|
+
Posts
|
|
156
|
+
</Link>;
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
The cache is small (a handful of recent pages) and short-lived, same-origin only, and never prefetches across a layout boundary. It's the difference between "snappy in dev" and "snappy on 4G."
|
|
160
|
+
|
|
161
|
+
### Fast refresh in dev
|
|
162
|
+
|
|
163
|
+
When you edit a component under `serve --dev`, the server restarts (quickly) and the client **re-renders each mounted component from its held snapshot with the new code — keeping its state**, instead of a full reload that would reset everything. Because component state is a signed snapshot and your `APP_KEY` doesn't change across a restart, that snapshot still verifies against the fresh server; the browser also refetches the stylesheet, so a newly-used Tailwind class shows up too. Increment a counter, tweak its template, save — the counter keeps its value and the change appears in place.
|
|
164
|
+
|
|
165
|
+
Two things to know:
|
|
166
|
+
|
|
167
|
+
- It deliberately does **not** re-run `onMount`. Editing your data-loading there
|
|
168
|
+
won't reflect until you reload manually — that is the price of preserving
|
|
169
|
+
state.
|
|
170
|
+
- If an edit changes a component's shape in a way the held snapshot cannot
|
|
171
|
+
satisfy, Flow falls back to a one-shot full reload rather than showing a
|
|
172
|
+
broken page. Production is unaffected — this only runs under the dev worker.
|
|
173
|
+
|
|
174
|
+
### Time-travel devtools
|
|
175
|
+
|
|
176
|
+
Because the engine signs and delta-encodes the **full component state** on every round-trip, the client already holds an exact, verifiable history of everything each component has been. Under `serve --dev`, Flow records that stream: one frame per applied patch,
|
|
177
|
+
plus the initial mount. You can then **scrub back to any frame**.
|
|
178
|
+
|
|
179
|
+
Jumping re-applies that frame's snapshot _and_ its HTML to the live component, so
|
|
180
|
+
both state and DOM restore exactly. No setup, no instrumentation in your components.
|
|
181
|
+
|
|
182
|
+
It surfaces as a **Timeline** tab in the [Zerotal devtools panel](/docs/devtools) (`@zerotal/devtools`) — Flow registers it there so all the framework's tooling lives in one place. If devtools isn't installed, Flow falls back to a standalone ⏱ panel so time-travel still works on its own — docked bottom-left, or set `data-flow-tl-corner="bottom-right"` (any corner) on `<html>` or `<body>` if that clashes with your layout. Each frame shows the action that produced it (`increment`, `$set`, `mount`, …), which state fields changed, and the time. Click a frame to jump there; a **⏵ Resume live** button returns to the latest. It's the fastest way to see how a bug's state evolved — perform the actions, then step backward through them.
|
|
183
|
+
|
|
184
|
+
There's also a console API for scripted inspection:
|
|
185
|
+
|
|
186
|
+
```js
|
|
187
|
+
__flow.timeline.frames(); // every recorded frame (seq, action, changed fields, snapshot, …)
|
|
188
|
+
__flow.timeline.framesFor(id); // frames for one component (its data-flow-id)
|
|
189
|
+
__flow.timeline.jump(seq); // restore the component to that frame (state + DOM)
|
|
190
|
+
__flow.timeline.live(id); // return a rewound component to its latest frame
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Two things to know: jumping is **client-only** — it re-applies a snapshot you already hold, with no server round-trip, so it's instant and side-effect-free. State is server-authoritative, though, so acting after a rewind has
|
|
194
|
+
consequences: the action sends the rewound snapshot to the server, which
|
|
195
|
+
continues authoritatively from there as a coherent new branch.
|
|
196
|
+
|
|
197
|
+
Use time travel to inspect and replay, then hit **Live** before resuming normal
|
|
198
|
+
use. Recording is capped to the most recent frames and runs **only** under the dev worker; production ships none of it.
|
|
199
|
+
|
|
200
|
+
### Dev error overlay
|
|
201
|
+
|
|
202
|
+
When an **unexpected** error is thrown, `serve --dev` shows a **full-screen
|
|
203
|
+
overlay** carrying the error class, message, and stack. It covers both a server
|
|
204
|
+
action and the initial `GET` render (`onMount` / `render`).
|
|
205
|
+
|
|
206
|
+
That is the same immediacy a client-side bundler gives you, for server-driven
|
|
207
|
+
components. The server attaches that detail **only under the dev worker**, so the overlay
|
|
208
|
+
never appears in production. No stack is ever sent to a browser there: an action
|
|
209
|
+
flashes its message, and an initial-render error returns a normal 500.
|
|
210
|
+
|
|
211
|
+
The overlay names the **action** and **component** that threw, dims framework/`node_modules` stack frames so your app frames stand out, and dismisses on Esc or a backdrop click. For an action error the component's patch still reconciles underneath, so dismissing returns you to a live page with its state intact — pair it with the [time-travel timeline](#time-travel-devtools) to see exactly how the state got there.
|
|
212
|
+
|
|
213
|
+
Only genuinely unexpected throws raise the overlay. The framework's own control-flow errors pass straight through to their normal
|
|
214
|
+
handling. Validation errors populate the error bag
|
|
215
|
+
(`error={this.errors.field}`), and **intended HTTP errors** — an auth `401` or
|
|
216
|
+
`403`, a `404`, a redirect — resolve to their proper status instead of a spurious
|
|
217
|
+
error screen.
|
|
218
|
+
|
|
219
|
+
### Durable & resumable state
|
|
220
|
+
|
|
221
|
+
Fast refresh keeps state across a _dev restart_; **`static durable`** keeps it across a _full client reload_. Opt a component in and its signed snapshot is persisted server-side after every
|
|
222
|
+
request, keyed by user (or session) and route.
|
|
223
|
+
|
|
224
|
+
A multi-step form, a long editor, or any in-progress flow then resumes
|
|
225
|
+
**exactly** — whether the user reloads the page, closes and reopens the tab, or
|
|
226
|
+
switches device. Nothing is stored in the browser:
|
|
227
|
+
|
|
228
|
+
```tsx
|
|
229
|
+
export class Wizard extends Component {
|
|
230
|
+
static durable = true; // or { ttl: "1h", scope: "user" | "session" }
|
|
231
|
+
|
|
232
|
+
@expose step = 1;
|
|
233
|
+
@expose name = "";
|
|
234
|
+
@expose next() {
|
|
235
|
+
this.step++;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
@expose finish() {
|
|
239
|
+
// …persist the result…
|
|
240
|
+
this.clearDurable(); // flow complete — forget the stored snapshot so the next visit is fresh
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
How it works: on a fresh `GET`, Flow looks for a valid stored snapshot for this
|
|
246
|
+
user and route. Finding one, it restores the snapshot and runs `onHydrate()`
|
|
247
|
+
**instead of `onMount()`**.
|
|
248
|
+
|
|
249
|
+
The resume therefore behaves like a WebSocket round-trip rather than a fresh
|
|
250
|
+
load — state comes back from the snapshot, so re-derive any `@transient` model in
|
|
251
|
+
`onHydrate()`. If there's no entry — or the snapshot fails its HMAC check (tampered, or your `APP_KEY` rotated) — it mounts fresh. Every subsequent action re-persists the latest snapshot; `this.clearDurable()` drops it.
|
|
252
|
+
|
|
253
|
+
A few things to know:
|
|
254
|
+
|
|
255
|
+
- **Keying & isolation.** `scope: "user"` (the default) keys by the authenticated user, falling back to the session when anonymous; `scope: "session"` always keys per-session (per-device, even when logged in). The snapshot is HMAC-signed and keyed by identity, so one user can never resume another's state. A user-scoped component with no user and no session simply doesn't persist.
|
|
256
|
+
- **Survives a redeploy — with a persistent store.** The default store is in-process (survives reconnect/tab-close/device-switch within the running process). To also survive a **server restart or redeploy**, swap in a persistent backend — `setDurableStore(store)` accepts any `{ get, set, delete }` (e.g. one backed by `@zerotal/cache`/Redis). Set a `ttl` (default 24h) to bound how long an abandoned flow lingers.
|
|
257
|
+
- **The URL vs. the snapshot.** On resume the stored snapshot wins, so `@url` props reflect the saved value rather than re-seeding from the current query string — resume-exactly semantics. Use `clearDurable()` at the natural end of a flow so a returning user isn't dropped back into a finished form.
|
|
258
|
+
|
|
259
|
+
## Next steps
|
|
260
|
+
|
|
261
|
+
- [Flow overview](/docs/flow) — the guide's front page and the rest of the sections.
|
|
262
|
+
- [Reference](/docs/flow/references) — every decorator, prop, and directive in one table.
|