@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.
Files changed (160) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +67 -0
  4. package/api-surface.md +283 -0
  5. package/docs/about.md +676 -0
  6. package/docs/admin/actions.md +183 -0
  7. package/docs/admin/auth.md +89 -0
  8. package/docs/admin/dashboard.md +155 -0
  9. package/docs/admin/extending-ui.md +229 -0
  10. package/docs/admin/extending.md +205 -0
  11. package/docs/admin/forms.md +155 -0
  12. package/docs/admin/index.md +144 -0
  13. package/docs/admin/operations.md +224 -0
  14. package/docs/admin/references.md +174 -0
  15. package/docs/admin/resources.md +132 -0
  16. package/docs/admin/structure.md +158 -0
  17. package/docs/admin/tables.md +185 -0
  18. package/docs/admin/testing.md +138 -0
  19. package/docs/ai.md +373 -0
  20. package/docs/application.md +435 -0
  21. package/docs/arch.md +263 -0
  22. package/docs/assets.md +755 -0
  23. package/docs/audit.md +367 -0
  24. package/docs/authentication.md +1000 -0
  25. package/docs/authorization.md +529 -0
  26. package/docs/broadcasting/channels.md +170 -0
  27. package/docs/broadcasting/client.md +108 -0
  28. package/docs/broadcasting/events.md +190 -0
  29. package/docs/broadcasting/index.md +163 -0
  30. package/docs/broadcasting/references.md +84 -0
  31. package/docs/broadcasting/testing.md +132 -0
  32. package/docs/cache.md +387 -0
  33. package/docs/carbon.md +830 -0
  34. package/docs/changelog.md +465 -0
  35. package/docs/client/auth.md +113 -0
  36. package/docs/client/errors.md +139 -0
  37. package/docs/client/files.md +118 -0
  38. package/docs/client/index.md +128 -0
  39. package/docs/client/references.md +58 -0
  40. package/docs/client/requests.md +131 -0
  41. package/docs/client/resilience.md +141 -0
  42. package/docs/client/testing.md +146 -0
  43. package/docs/commands.md +450 -0
  44. package/docs/components.md +2427 -0
  45. package/docs/config-system.md +258 -0
  46. package/docs/container.md +596 -0
  47. package/docs/context.md +579 -0
  48. package/docs/contributing.md +164 -0
  49. package/docs/controllers.md +354 -0
  50. package/docs/conventions.md +466 -0
  51. package/docs/cookies.md +101 -0
  52. package/docs/csrf.md +206 -0
  53. package/docs/database.md +476 -0
  54. package/docs/deployment.md +434 -0
  55. package/docs/devtools.md +1013 -0
  56. package/docs/email-verification.md +320 -0
  57. package/docs/encryption.md +380 -0
  58. package/docs/errors.md +317 -0
  59. package/docs/events.md +420 -0
  60. package/docs/flow/components.md +846 -0
  61. package/docs/flow/decorators.md +458 -0
  62. package/docs/flow/events.md +420 -0
  63. package/docs/flow/forms.md +551 -0
  64. package/docs/flow/index.md +568 -0
  65. package/docs/flow/layouts.md +793 -0
  66. package/docs/flow/lifecycle.md +336 -0
  67. package/docs/flow/pagination.md +402 -0
  68. package/docs/flow/performance.md +262 -0
  69. package/docs/flow/references.md +317 -0
  70. package/docs/flow/routing.md +550 -0
  71. package/docs/flow/testing.md +447 -0
  72. package/docs/getting-started.md +286 -0
  73. package/docs/health.md +269 -0
  74. package/docs/helpers.md +423 -0
  75. package/docs/i18n.md +414 -0
  76. package/docs/index.md +65 -0
  77. package/docs/inertia/build.md +95 -0
  78. package/docs/inertia/devtools.md +197 -0
  79. package/docs/inertia/index.md +267 -0
  80. package/docs/inertia/middleware.md +81 -0
  81. package/docs/inertia/props.md +507 -0
  82. package/docs/inertia/references.md +144 -0
  83. package/docs/inertia/rendering.md +189 -0
  84. package/docs/inertia/ssr.md +102 -0
  85. package/docs/inspirations.md +257 -0
  86. package/docs/lifecycle.md +331 -0
  87. package/docs/lock.md +454 -0
  88. package/docs/logger.md +517 -0
  89. package/docs/media.md +582 -0
  90. package/docs/middleware.md +509 -0
  91. package/docs/migrations.md +529 -0
  92. package/docs/monitor.md +377 -0
  93. package/docs/notifications.md +848 -0
  94. package/docs/orm/casts.md +365 -0
  95. package/docs/orm/factories.md +231 -0
  96. package/docs/orm/index.md +663 -0
  97. package/docs/orm/lifecycle.md +421 -0
  98. package/docs/orm/queries.md +692 -0
  99. package/docs/orm/relationships.md +440 -0
  100. package/docs/orm/serialization.md +251 -0
  101. package/docs/package-development.md +536 -0
  102. package/docs/pagination.md +340 -0
  103. package/docs/password-reset.md +335 -0
  104. package/docs/providers.md +447 -0
  105. package/docs/query-builder.md +465 -0
  106. package/docs/queue.md +549 -0
  107. package/docs/rate-limiting.md +253 -0
  108. package/docs/responses.md +336 -0
  109. package/docs/roles-and-2fa.md +418 -0
  110. package/docs/routing.md +1008 -0
  111. package/docs/scaffolding.md +335 -0
  112. package/docs/scheduler.md +533 -0
  113. package/docs/seeding.md +229 -0
  114. package/docs/session.md +451 -0
  115. package/docs/social.md +524 -0
  116. package/docs/storage.md +622 -0
  117. package/docs/structure.md +230 -0
  118. package/docs/support-policy.md +114 -0
  119. package/docs/telemetry.md +505 -0
  120. package/docs/tenancy.md +608 -0
  121. package/docs/testing/browser.md +153 -0
  122. package/docs/testing/console.md +130 -0
  123. package/docs/testing/database.md +238 -0
  124. package/docs/testing/flow-browser.md +216 -0
  125. package/docs/testing/http.md +441 -0
  126. package/docs/testing/index.md +240 -0
  127. package/docs/testing/mocking.md +410 -0
  128. package/docs/upgrade.md +192 -0
  129. package/docs/validator.md +549 -0
  130. package/docs/view.md +453 -0
  131. package/package.json +60 -0
  132. package/src/bin/mcp.ts +73 -0
  133. package/src/config.ts +63 -0
  134. package/src/errors.ts +27 -0
  135. package/src/index.ts +56 -0
  136. package/src/install/ArchInstallCommand.ts +189 -0
  137. package/src/install/detect.ts +98 -0
  138. package/src/install/guidelines.ts +290 -0
  139. package/src/install/markers.ts +77 -0
  140. package/src/install/mcpConfig.ts +81 -0
  141. package/src/mcp/index.ts +27 -0
  142. package/src/mcp/jsonrpc.ts +133 -0
  143. package/src/mcp/server.ts +307 -0
  144. package/src/mcp/stdio.ts +111 -0
  145. package/src/mcp/types.ts +146 -0
  146. package/src/probe/ArchProbeCommand.ts +48 -0
  147. package/src/probe/sentinel.ts +13 -0
  148. package/src/probe/topics.ts +291 -0
  149. package/src/provider/ArchProvider.ts +101 -0
  150. package/src/tools/_probe.ts +164 -0
  151. package/src/tools/apiSurface.ts +241 -0
  152. package/src/tools/appInfo.ts +88 -0
  153. package/src/tools/baselines.ts +210 -0
  154. package/src/tools/context.ts +37 -0
  155. package/src/tools/doctor.ts +93 -0
  156. package/src/tools/index.ts +50 -0
  157. package/src/tools/logs.ts +265 -0
  158. package/src/tools/routes.ts +133 -0
  159. package/src/tools/schema.ts +119 -0
  160. 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.