@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,550 @@
1
+ ---
2
+ title: Flow Routing
3
+ description: Map URLs to Flow pages — file-based routes, route parameters, and navigation.
4
+ ---
5
+
6
+ # Routing
7
+
8
+ Flow pages are plain TypeScript classes. Register them as routes with `Router.flow()`, or place them in a directory and let file-based routing discover them automatically. Either way, the same page class powers both the initial HTTP render and all subsequent WebSocket updates.
9
+
10
+ ## The Router.flow method
11
+
12
+ `Router.flow(path, PageClass, middleware?)` registers a `Component` subclass as a `GET` route. The third argument accepts an array of middleware classes that run on the **initial GET and on every WebSocket update** — this is Flow's persistent middleware model (no separate "attach middleware per WS frame" step required).
13
+
14
+ ```typescript
15
+ // routes/web.ts
16
+ import { Router } from "zerotal";
17
+ import { DashboardPage } from "#app/flow/DashboardPage.tsx";
18
+ import { PostsPage } from "#app/flow/PostsPage.tsx";
19
+ import { PostDetailPage } from "#app/flow/PostDetailPage.tsx";
20
+ import { AdminPage } from "#app/flow/AdminPage.tsx";
21
+ import { RequireAuthMiddleware } from "#app/middleware/RequireAuth.ts";
22
+ import { RequireAdminMiddleware } from "#app/middleware/RequireAdmin.ts";
23
+
24
+ // No middleware — anyone can access
25
+ Router.flow("/posts", PostsPage);
26
+
27
+ // Single dynamic segment
28
+ Router.flow("/posts/:slug", PostDetailPage);
29
+
30
+ // Auth guard — middleware re-runs on every WebSocket update
31
+ Router.flow("/dashboard", DashboardPage, [RequireAuthMiddleware]);
32
+
33
+ // Multiple guards
34
+ Router.flow("/admin", AdminPage, [RequireAuthMiddleware, RequireAdminMiddleware]);
35
+ ```
36
+
37
+ ### Named routes
38
+
39
+ Chain `.name()` to give a route a name for reverse URL generation:
40
+
41
+ ```typescript
42
+ Router.flow("/dashboard", DashboardPage, [RequireAuthMiddleware]).name("dashboard");
43
+ Router.flow("/posts", PostsPage).name("posts.index");
44
+ Router.flow("/posts/:slug", PostDetailPage).name("posts.show");
45
+ ```
46
+
47
+ ### Route groups
48
+
49
+ Use `Router.group()` to share a prefix and/or middleware across several Flow routes:
50
+
51
+ ```typescript
52
+ Router.group({ prefix: "/app", middleware: [RequireAuthMiddleware] }, () => {
53
+ Router.flow("/dashboard", DashboardPage); // /app/dashboard
54
+ Router.flow("/profile", ProfilePage); // /app/profile
55
+ Router.flow("/settings", SettingsPage); // /app/settings
56
+ });
57
+
58
+ Router.group(
59
+ { prefix: "/admin", middleware: [RequireAuthMiddleware, RequireAdminMiddleware] },
60
+ () => {
61
+ Router.flow("/", AdminDashboardPage); // /admin
62
+ Router.flow("/users", AdminUsersPage); // /admin/users
63
+ Router.flow("/posts", AdminPostsPage); // /admin/posts
64
+ },
65
+ );
66
+ ```
67
+
68
+ Middleware declared on the group is persistent: it re-runs on every WebSocket update for every page in the group.
69
+
70
+ ## File-based routing
71
+
72
+ When you call `.fileBasedRouting()` on `Application`, the framework scans the given directory and auto-registers any file that exports a `Component` subclass. No import required in a route file.
73
+
74
+ ```typescript
75
+ // bootstrap/app.ts
76
+ import { Application, basePath } from "zerotal";
77
+ import providers from "./providers.ts";
78
+
79
+ export default Application.create({ providers })
80
+ .routing({ web: basePath("routes/web.ts") })
81
+ .fileBasedRouting({ web: basePath("app/flow") });
82
+ ```
83
+
84
+ Any `Component` subclass exported from a file under `app/flow/` is registered automatically. The URL path is derived from the file path relative to the root directory:
85
+
86
+ | File | URL |
87
+ | ------------------------------------ | ----------------------- |
88
+ | `app/flow/DashboardPage.tsx` | `/dashboard-page` |
89
+ | `app/flow/PostsPage.tsx` | `/posts-page` |
90
+ | `app/flow/posts/IndexPage.tsx` | `/posts/index-page` |
91
+ | `app/flow/posts/[slug].tsx` | `/posts/:slug` |
92
+ | `app/flow/posts/[slug]/comments.tsx` | `/posts/:slug/comments` |
93
+ | `app/flow/admin/users/[id].tsx` | `/admin/users/:id` |
94
+
95
+ Class names become kebab-case URL segments: `DashboardPage` → `/dashboard-page`. For cleaner URLs, use explicit `Router.flow()` registration in a route file alongside file-based routing — both can coexist in the same app.
96
+
97
+ ### Dynamic segments
98
+
99
+ Name a file (or directory) with square brackets to create a dynamic route segment:
100
+
101
+ ```
102
+ app/flow/
103
+ posts/
104
+ [slug].tsx → /posts/:slug
105
+ [slug]/
106
+ comments.tsx → /posts/:slug/comments
107
+ users/
108
+ [id]/
109
+ profile.tsx → /users/:id/profile
110
+ edit.tsx → /users/:id/edit
111
+ ```
112
+
113
+ ### Per-file middleware
114
+
115
+ Export a `middleware` array from a file-route to attach middleware to that specific page:
116
+
117
+ ```typescript
118
+ // app/flow/admin/DashboardPage.tsx
119
+ import { RequireAuthMiddleware } from "#app/middleware/RequireAuth.ts";
120
+ import { RequireAdminMiddleware } from "#app/middleware/RequireAdmin.ts";
121
+
122
+ export const middleware = [RequireAuthMiddleware, RequireAdminMiddleware];
123
+
124
+ export class DashboardPage extends Component {
125
+ // …
126
+ }
127
+ ```
128
+
129
+ Or use a `_middleware.ts` file in a directory to apply middleware to every file in that directory:
130
+
131
+ ```typescript
132
+ // app/flow/admin/_middleware.ts
133
+ import { RequireAuthMiddleware } from "#app/middleware/RequireAuth.ts";
134
+ import { RequireAdminMiddleware } from "#app/middleware/RequireAdmin.ts";
135
+
136
+ export const middleware = [RequireAuthMiddleware, RequireAdminMiddleware];
137
+ ```
138
+
139
+ ## Accessing route parameters
140
+
141
+ ### Query string parameters — @url
142
+
143
+ For query string parameters (`/posts?page=2&search=typescript`), use the `@url` decorator. The field is filled from the URL on the first render, and stays in sync as the value changes:
144
+
145
+ ```typescript
146
+ @url page: number = 1;
147
+ @url search: string = "";
148
+ @url status: string = "all";
149
+
150
+ // Custom parameter name: /posts?q=typescript
151
+ @url({ as: "q" }) query: string = "";
152
+
153
+ // Push browser history on change (back button works)
154
+ @url({ history: "push" }) tab: string = "overview";
155
+ ```
156
+
157
+ ### Query-aware navigation
158
+
159
+ Where `@url` syncs a reactive prop **to** the URL, `this.currentUrl()` and `this.navigateCurrent()` go the other way — they **build** a URL from the one you're on with some query params changed, for filter links and instant filtering. Both are client-only helpers (the compiler rewrites them to the client runtime), so they update without a server round-trip to start.
160
+
161
+ ```typescript
162
+ // Build a URL from the current one — returns a string, does NOT navigate.
163
+ this.currentUrl({ query: { page: 3 } });
164
+ // /posts?search=ts&page=2 → /posts?search=ts&page=3
165
+
166
+ // Build that URL and SPA-navigate to it (layout stays mounted) — returns a Promise.
167
+ this.navigateCurrent({ query: { status: "active" } });
168
+ ```
169
+
170
+ The merge rules:
171
+
172
+ - Params you pass are **added or updated**; params you don't mention are **preserved**.
173
+ - A value of `null`, `undefined`, or `""` **removes** that param (so `0` and `false` are kept).
174
+ - `hash: "section-2"` sets the hash; `hash: ""` clears it; omit it to leave the hash alone.
175
+
176
+ **Use `currentUrl()` in a binding** — an attribute value or a text child. The compiler turns it into a reactive client binding, so the link always reflects the current URL:
177
+
178
+ ```tsx
179
+ // Pagination links that preserve the active search / filters
180
+ <a href={this.currentUrl({ query: { page: this.page - 1 } })} flow:navigate>Previous</a>
181
+ <a href={this.currentUrl({ query: { page: this.page + 1 } })} flow:navigate>Next</a>
182
+ ```
183
+
184
+ **Use `navigateCurrent()` in a handler** — perfect for instant filters that navigate as the user picks:
185
+
186
+ ```tsx
187
+ <select onChange={(e) => this.navigateCurrent({ query: { status: e.target.value || null } })}>
188
+ <option value="">All</option>
189
+ <option value="active">Active</option>
190
+ <option value="archived">Archived</option>
191
+ </select>
192
+
193
+ // Clear a filter — passing "" removes the param
194
+ <button onClick={() => this.navigateCurrent({ query: { search: "" } })}>Clear search</button>
195
+ ```
196
+
197
+ Like any navigation, this lands at the top of the page. A filter sitting partway
198
+ down is the case where that's wrong — the user is looking at the control they
199
+ just changed, and the results move out from under them. Pass `preserveScroll` to
200
+ leave the viewport where it is:
201
+
202
+ ```tsx
203
+ <select
204
+ onChange={(e) =>
205
+ this.navigateCurrent({ query: { status: e.target.value || null }, preserveScroll: true })
206
+ }
207
+ >
208
+ ```
209
+
210
+ Pagination is the opposite case: page 2 should start at the top, so leave it off.
211
+ `<Link preserveScroll>` does the same thing for a link — see
212
+ [Components](/docs/flow/components).
213
+
214
+ > **Warning** — Both helpers are client-only: they have no value on the server and throw if called from a server action or `onMount()`. Use them directly inside JSX bindings (an attribute value, a text child, or an `onClick`/`onChange` handler) — the compiler rewrites those to the `$flow.currentUrl` / `$flow.navigateCurrent` client magics. They're fully type-checked, no `$`-prefixed syntax. To navigate from server code instead, return a `redirect()`.
215
+
216
+ ### Path parameters
217
+
218
+ Name a field after the segment and it arrives filled. `/posts/:post` names the `Post` model, so the page receives the loaded record — no query, and no lookup code:
219
+
220
+ ```typescript
221
+ // routes/web.ts — nothing to declare; :post is a Post
222
+ Router.flow("/posts/:post", PostDetailPage);
223
+ ```
224
+
225
+ ```typescript
226
+ export class PostDetailPage extends Component {
227
+ @locked post!: Post; // :post — the record, already loaded
228
+
229
+ @expose async deletePost(): Promise<void> {
230
+ await this.post.delete();
231
+ this.redirect("/posts");
232
+ this.flash("Post deleted.");
233
+ }
234
+
235
+ override async render() {
236
+ return (
237
+ <div>
238
+ <h1>{this.post.title}</h1>
239
+ <div class="prose">{this.post.body}</div>
240
+ <button onClick={this.deletePost}>Delete</button>
241
+ </div>
242
+ );
243
+ }
244
+ }
245
+ ```
246
+
247
+ A segment that names a model arrives as the record; one that doesn't arrives as its plain string, so `@locked slug = ""` on `/posts/:slug` is filled the same way.
248
+
249
+ Flow does not decide how a segment resolves — the router does, and the page just receives the result. A record is looked up by primary key unless the model says otherwise, so resolving by a slug, or only publishing published posts, is [written once on the model](/docs/routing#the-model-owns-its-lookup) and changes nothing here.
250
+
251
+ Only `@locked` and `@expose` fields are filled, and only from segments the route actually matched. A plain undecorated property is never written from the URL.
252
+
253
+ When the field's name differs from the segment, `@param` says where it comes from — either the segment's name, or the model:
254
+
255
+ ```typescript
256
+ export class PostDetailPage extends Component {
257
+ @locked @param(Post) article!: Post; // whichever segment resolved to a Post
258
+ @locked @param("post") alsoArticle!: Post; // or name the segment
259
+ @locked @param year: string = ""; // bare @param = the field's own name
260
+ }
261
+ ```
262
+
263
+ Passing the model is the sturdier of the two — the field says what it wants and never has to track what a route called it. A model claims one segment name (its class name, or its `implicitBindingKey`), so there is nothing to be ambiguous about. Use the string form for the rare route that binds a second segment to the same model by hand; `@param(Model)` takes the first one.
264
+
265
+ ### Reading the rest of the request
266
+
267
+ `onBoot()` and `onMount()` also receive the request itself — the same `HttpContext` a controller action gets — for anything the URL doesn't carry. It types `ctx.params` only, so the signed-in user is `{ user }`, not `params.user`:
268
+
269
+ ```typescript
270
+ override async onMount({ user }: HttpContext) {
271
+ this.canEdit = user?.id === this.post.authorId;
272
+ }
273
+ ```
274
+
275
+ The argument is optional, because a component can also be created outside a request — in a test, for example.
276
+
277
+ ### Child components
278
+
279
+ A URL segment fills the **page**. `/posts/:post` gives the page its `post`; the components inside it get nothing from the URL, even if one of their fields happens to share the segment's name. If a child needs the post, the page hands it over:
280
+
281
+ ```tsx
282
+ // in the page's render()
283
+ <PostCard post={this.post} compact />
284
+ ```
285
+
286
+ A prop lands on the field of the same name, before any hook runs:
287
+
288
+ ```typescript
289
+ export class PostCard extends Component {
290
+ @locked post!: Post; // required — no default
291
+ @locked compact = false; // optional — false when the page omits it
292
+ @locked heading = "";
293
+
294
+ override async onMount() {
295
+ this.heading = this.post.title.toUpperCase(); // the prop is already here
296
+ }
297
+ }
298
+ ```
299
+
300
+ The initialiser is the convention: a field that has one is optional and falls back to it, and a field without one is required — so a page that forgets to pass it breaks on first use instead of quietly rendering the wrong thing.
301
+
302
+ A child still gets the request in `onBoot(ctx)` / `onMount(ctx)` — session, signed-in user, headers — like any page. It simply never reads the URL's segments, which is what lets the same `PostCard` work on `/posts/:post`, inside a list, and on a page with no segments at all.
303
+
304
+ Mark props `@locked` (or `@expose`) so their values survive round-trips in the child's own snapshot.
305
+
306
+ > **Warning** — Fields are filled from the URL on the first request only. A WebSocket action carries no URL, so the value comes back from the component's snapshot instead — which is why a model is never re-queried on every click. It also means you should not read `ctx.params` yourself in `onBoot()`: that hook runs on every request, after the snapshot has been restored, so it would blank the value on the first action.
307
+
308
+ ### Integer route params
309
+
310
+ Coerce string params to numbers with `ctx.integer()`:
311
+
312
+ ```typescript
313
+ override async onMount(ctx: HttpContext) {
314
+ this.userId = ctx.integer("id") ?? 0;
315
+ }
316
+ ```
317
+
318
+ ### All available request methods inside a Flow component
319
+
320
+ `onBoot()` and `onMount()` are handed the context directly. Everywhere else — an
321
+ `@expose`d action, or a service the page calls — reach for the `request()` helper,
322
+ which returns the same object from request-scoped storage:
323
+
324
+ ```typescript
325
+ import { request } from "zerotal";
326
+
327
+ // Inside an @expose method, which takes its own arguments rather than a context:
328
+ const ctx = request();
329
+
330
+ ctx.params.slug; // string — matched route segment (initial GET only)
331
+ ctx.params.post; // Post — a resolved route-model binding (initial GET only)
332
+ ctx.url.searchParams.get("q"); // string | null — raw query string access
333
+ ctx.string("search"); // string | undefined — reads params + query string
334
+ ctx.integer("page", 1); // number — coerced integer with fallback
335
+ ctx.boolean("active"); // boolean
336
+ ctx.user; // AuthenticatedUser | undefined — set by AuthMiddleware
337
+ ctx.ip(); // string | null — client IP
338
+ ```
339
+
340
+ ## Accessing the session
341
+
342
+ ### The @session decorator — a field backed by the session
343
+
344
+ `@session` binds a field to a session key, so its value survives a browser refresh. Reads and writes go straight to the session; nothing is kept in the component's snapshot, so the browser never sees the value:
345
+
346
+ ```typescript
347
+ @session preferredTheme: string = "light"; // the session's `preferredTheme`
348
+ @session lastVisitedTab: string = "overview";
349
+
350
+ @session({ key: "cart_count" }) cartCount: number = 0; // a differently-named key
351
+ @session({ scoped: true }) wizardStep: number = 1; // flow:CheckoutPage:wizardStep
352
+ ```
353
+
354
+ The key is the field's own name by default, so the value is the same one a controller or another component reads. Pass `scoped: true` for working state that belongs to this page alone — a wizard step, a half-finished draft — and the key is namespaced to the component instead.
355
+
356
+ Requires `SessionMiddleware` on the route. See [Decorators](/docs/flow/decorators#the-session-decorator) for the full options.
357
+
358
+ ### Reading the session directly
359
+
360
+ For values that aren't a field on this component — a cart, a flash bag, anything you set elsewhere — use the [`Session`](/docs/session) facade. It resolves the in-flight request's session, so it works in any hook or action:
361
+
362
+ ```typescript
363
+ import { Session } from "@zerotal/session";
364
+
365
+ override async onMount() {
366
+ if (Session.has("cart")) {
367
+ this.cartItems = Session.get<CartItem[]>("cart") ?? [];
368
+ }
369
+ }
370
+
371
+ @expose async addToCart(id: number): Promise<void> {
372
+ Session.set("cart", [...this.cartItems, await Product.findOrFail(id)]);
373
+ }
374
+ ```
375
+
376
+ `ctx.session` is the same store if you already have the context in hand.
377
+
378
+ Reach for `@session` when the value is a field on the component; reach for the facade when it isn't.
379
+
380
+ ## Accessing the authenticated user
381
+
382
+ When `AuthMiddleware` (or your `RequireAuthMiddleware`) runs, it populates `ctx.user`. Access it via `request()`:
383
+
384
+ ```typescript
385
+ import { request } from "zerotal";
386
+
387
+ export class ProfilePage extends Component {
388
+ @locked userId: number = 0;
389
+ @locked userName: string = "";
390
+
391
+ override async onBoot() {
392
+ const user = request().user;
393
+ this.userId = user?.id ?? 0;
394
+ this.userName = user?.name ?? "";
395
+ }
396
+
397
+ override async onMount() {
398
+ if (!this.userId) {
399
+ this.redirect("/login");
400
+ return;
401
+ }
402
+ this.profile = await Profile.where("user_id", this.userId).first();
403
+ }
404
+ }
405
+ ```
406
+
407
+ Because `onBoot()` runs on every request (initial GET and WebSocket), the auth check stays current for the full session — even if the user logs out in another tab.
408
+
409
+ ## Page metadata
410
+
411
+ Declare static properties on the page class to control the document `<title>`, inject `<head>` content, and attach a layout:
412
+
413
+ ```typescript
414
+ export class DashboardPage extends Component {
415
+ // Sets <title>Dashboard</title> on the initial render
416
+ static title = "Dashboard";
417
+
418
+ // Injected into <head> on the initial render
419
+ static head = `
420
+ <link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin>
421
+ <meta name="robots" content="noindex">
422
+ `;
423
+
424
+ // Wrap the page in this layout (layout is preserved on navigate)
425
+ static layout = AppLayout;
426
+ }
427
+ ```
428
+
429
+ For per-action dynamic titles, use `this.title()` inside an action:
430
+
431
+ ```typescript
432
+ @expose async loadPost(slug: string): Promise<void> {
433
+ this.post = await Post.where("slug", slug).firstOrFail();
434
+ this.title(`${this.post.title} — My App`);
435
+ }
436
+ ```
437
+
438
+ For per-render `<head>` content (meta tags, OG tags), use `<Head>` inside `render()`:
439
+
440
+ ```tsx
441
+ import { Head } from "@zerotal/flow";
442
+
443
+ override async render() {
444
+ return (
445
+ <div>
446
+ <Head>
447
+ <title>{this.post?.title ?? "Post"} — My App</title>
448
+ <meta name="description" content={this.post?.excerpt ?? ""} />
449
+ <meta property="og:image" content={this.post?.coverUrl ?? ""} />
450
+ </Head>
451
+ <h1>{this.post?.title}</h1>
452
+ </div>
453
+ );
454
+ }
455
+ ```
456
+
457
+ ## Complete example
458
+
459
+ A fully wired page with a dynamic route param, auth, session, query string, and layout:
460
+
461
+ ```typescript
462
+ import { Component, expose, locked, url, session } from "@zerotal/flow";
463
+ import { request } from "zerotal";
464
+ import { RequireAuthMiddleware } from "#app/middleware/RequireAuth.ts";
465
+ import { AppLayout } from "#app/flow/AppLayout.tsx";
466
+ import { Post } from "#app/models/Post.ts";
467
+
468
+ export class PostDetailPage extends Component {
469
+ static layout = AppLayout;
470
+ static title = "Post";
471
+
472
+ // Route: /posts/:slug
473
+ // Register: Router.flow("/posts/:slug", PostDetailPage, [RequireAuthMiddleware]);
474
+
475
+ @locked slug: string = "";
476
+ @locked post: Post | null = null;
477
+ @expose editing: boolean = false;
478
+
479
+ @url tab: string = "content"; // ?tab=content|comments
480
+ @session viewed: boolean = false; // persists across browser refreshes
481
+
482
+ override async onBoot() {
483
+ // Read the :slug segment on every request (initial + WebSocket updates)
484
+ this.slug = request().params.slug ?? "";
485
+ }
486
+
487
+ override async onMount() {
488
+ this.post = await Post.query()
489
+ .where("slug", this.slug)
490
+ .withRelationships(["author", "tags"])
491
+ .first();
492
+
493
+ if (!this.post) {
494
+ this.redirect("/posts");
495
+ return;
496
+ }
497
+
498
+ // Track that the user has viewed this post (session-persisted)
499
+ if (!this.viewed) {
500
+ await this.post.increment("view_count");
501
+ this.viewed = true;
502
+ }
503
+ }
504
+
505
+ @expose async publish(): Promise<void> {
506
+ if (!this.post) return;
507
+ await this.post.fill({ status: "published" }).save();
508
+ this.flash(`"${this.post.title}" is now live.`);
509
+ this.editing = false;
510
+ }
511
+
512
+ @expose async delete(): Promise<void> {
513
+ if (!this.post) return;
514
+ await this.post.delete();
515
+ this.dispatch("post-deleted", { slug: this.slug });
516
+ this.redirect("/posts");
517
+ this.flash("Post deleted.");
518
+ }
519
+
520
+ override async render() {
521
+ if (!this.post) return <div>Not found.</div>;
522
+
523
+ return (
524
+ <div class="max-w-3xl mx-auto py-12 px-6">
525
+ <h1 class="text-3xl font-bold mb-4">{this.post.title}</h1>
526
+ <p class="text-sm text-gray-500 mb-8">
527
+ By {this.post.author?.name} · {this.post.view_count} views
528
+ </p>
529
+
530
+ <div class="prose">{this.post.body}</div>
531
+
532
+ <div class="mt-8 flex gap-3">
533
+ <button onClick={() => (this.editing = !this.editing)}>
534
+ {this.editing ? "Cancel" : "Edit"}
535
+ </button>
536
+ {this.post.status === "draft" && (
537
+ <button onClick={this.publish}>Publish</button>
538
+ )}
539
+ <button onClick={this.delete} class="text-red-600">Delete</button>
540
+ </div>
541
+ </div>
542
+ );
543
+ }
544
+ }
545
+ ```
546
+
547
+ ## Next steps
548
+
549
+ - [Flow overview](/docs/flow) — the guide's front page and the rest of the sections.
550
+ - [Reference](/docs/flow/references) — every decorator, prop, and directive in one table.