@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,551 @@
1
+ ---
2
+ title: Flow Forms, Validation & Uploads
3
+ description: Two-way bound inputs, real-time validation, and server-handled file uploads.
4
+ ---
5
+
6
+ # Forms & Validation
7
+
8
+ Validate input, bind form fields to component state with two-way binding, encapsulate complex forms in form objects, and paginate result sets.
9
+
10
+ ## Validation rules
11
+
12
+ Attach rules with `@validate` on individual fields, or pass them explicitly to `this.validate()`:
13
+
14
+ ```typescript
15
+ import { expose, validate } from "@zerotal/flow";
16
+
17
+ @expose @validate((rule) => rule.required().email()) email: string = "";
18
+ @expose @validate((rule) => rule.required().min(8)) password: string = "";
19
+ @expose @validate((rule) => rule.required().min(2).max(50)) name: string = "";
20
+ @expose @validate((rule) => rule.required().in(["admin", "user", "guest"])) role: string = "user";
21
+ @expose @validate((rule) => rule.number().min(0)) age?: number;
22
+ ```
23
+
24
+ Rules use the framework validator's fluent chain (`@zerotal/validator`'s `RuleBuilder`), not
25
+ strings. `rule.required()` is shorthand for a required string; for other types start from the typed
26
+ builder (`rule.number()`, …). The same `@validate` rule also powers
27
+ [real-time validation](/docs/flow/decorators#real-time-validation) when a field is bound with
28
+ `flow:model.live`.
29
+
30
+ Call `this.validate()` at the start of your action. It reads the `@validate` rules and throws a `ValidationError` if any fail — Flow catches it, populates `this.errors`, and re-renders:
31
+
32
+ ```typescript
33
+ @expose async register(): Promise<void> {
34
+ await this.validate(); // uses @validate rules declared on the class
35
+
36
+ // Only reached if all fields are valid:
37
+ const user = await User.create({
38
+ name: this.name,
39
+ email: this.email,
40
+ password: this.password,
41
+ role: this.role,
42
+ });
43
+ this.redirect("/dashboard");
44
+ }
45
+ ```
46
+
47
+ Pass explicit rules to override or extend `@validate`:
48
+
49
+ ```typescript
50
+ @expose async update(): Promise<void> {
51
+ await this.validate({
52
+ email: (rule) => rule.required().email(),
53
+ password: (rule) => rule.string().optional().min(8),
54
+ role: (rule) => rule.required().in(["admin", "user"]),
55
+ });
56
+
57
+ await this.user.fill({ email: this.email, role: this.role }).save();
58
+ this.flash("Profile updated.");
59
+ }
60
+ ```
61
+
62
+ ### Available validation rules
63
+
64
+ | Rule | Description |
65
+ | --------------------- | ------------------------------------------------------ |
66
+ | `required` | Field must be present and non-empty |
67
+ | `nullable` | Field can be null/undefined — skip further rules if so |
68
+ | `string` | Must be a string |
69
+ | `numeric` | Must be numeric |
70
+ | `integer` | Must be an integer |
71
+ | `boolean` | Must be true/false |
72
+ | `array` | Must be an array |
73
+ | `email` | Must be a valid email address |
74
+ | `min:N` | String: min length N; Number: min value N |
75
+ | `max:N` | String: max length N; Number: max value N |
76
+ | `between:N,M` | Value must be between N and M |
77
+ | `in:a,b,c` | Must be one of the listed values |
78
+ | `confirmed` | Must match `{fieldName}_confirmation` |
79
+ | `unique:table,column` | Must not exist in the database table |
80
+ | `exists:table,column` | Must exist in the database table |
81
+
82
+ ## Showing validation errors in the template
83
+
84
+ Pass a field off `this.errors` to the `error` prop. It renders the first message for that field and hides itself when the field is valid — no manual show/hide logic:
85
+
86
+ ```tsx
87
+ <input value={this.email} />
88
+ <span error={this.errors.email} class="text-sm text-red-500" />
89
+
90
+ <input value={this.name} />
91
+ <span error={this.errors.name} class="text-sm text-red-500" />
92
+ ```
93
+
94
+ Use the `<Field>` component for accessible label + error wiring:
95
+
96
+ ```tsx
97
+ import { Field } from "@zerotal/flow";
98
+
99
+ <Field label="Email" error={this.errors.email}>
100
+ <input value={this.email} class="input" />
101
+ </Field>
102
+
103
+ <Field label="Password" description="At least 8 characters." error={this.errors.password}>
104
+ <input type="password" value={this.password} class="input" />
105
+ </Field>
106
+ ```
107
+
108
+ Show all errors at once with `<Errors>`:
109
+
110
+ ```tsx
111
+ import { Errors } from "@zerotal/flow";
112
+
113
+ <Errors /> {/* all current errors */}
114
+ <Errors only={["email", "name"]} /> {/* just these fields */}
115
+ ```
116
+
117
+ ### Checking errors in server code
118
+
119
+ ```typescript
120
+ // Check if any errors exist
121
+ if (this.errors.any()) {
122
+ return;
123
+ }
124
+
125
+ // Check a specific field
126
+ if (this.errors.has("email")) {
127
+ this.flash("Please fix the email field.", "error");
128
+ return;
129
+ }
130
+ ```
131
+
132
+ ### Manual errors
133
+
134
+ ```typescript
135
+ this.addError("email", "That email address is already taken.");
136
+ this.addError("username", "Username must be unique.");
137
+
138
+ this.resetValidation(); // clear all errors
139
+ this.resetValidation("email"); // clear one field
140
+ ```
141
+
142
+ ## Two-way model binding
143
+
144
+ Pass state to `value` (or `checked`). Flow makes it two-way for `@expose` properties and read-only for `@locked` ones — no helper needed:
145
+
146
+ ```tsx
147
+ {/* Text inputs */}
148
+ <input value={this.name} />
149
+ <input value={this.email} />
150
+ <textarea value={this.bio} />
151
+
152
+ {/* Live sync on each keystroke (triggers a server round-trip per keystroke) */}
153
+ <input value={this.search} live placeholder="Search…" />
154
+
155
+ {/* Sync on blur (round-trip when the input loses focus) */}
156
+ <input value={this.title} blur />
157
+
158
+ {/* Checkboxes */}
159
+ <input type="checkbox" checked={this.agree} />
160
+
161
+ {/* Select — the <option> matching the bound value is marked `selected` automatically,
162
+ so the control shows (and submits) the right choice on first render */}
163
+ <select value={this.role}>
164
+ <option value="admin">Admin</option>
165
+ <option value="user">User</option>
166
+ <option value="guest">Guest</option>
167
+ </select>
168
+ ```
169
+
170
+ The bound `<select>` resolves its `flow:model` from `value={this.role}` and marks the matching `<option selected>` for you — including when the options are mapped inside a wrapper component (`<MySelect value={this.role} options={…} />`). You never write `selected` by hand.
171
+
172
+ ## Form objects
173
+
174
+ Bundle related fields, their validation rules, and reset/fill helpers into a reusable `Form` subclass, then mount it on a component as a single `@expose` property. This keeps your component class lean and makes the form reusable.
175
+
176
+ ```typescript
177
+ import { Form } from "@zerotal/flow";
178
+ import type { RuleBuilder } from "@zerotal/validator";
179
+
180
+ export class LoginForm extends Form {
181
+ email = "";
182
+ password = "";
183
+ remember = false;
184
+
185
+ rules(v: RuleBuilder) {
186
+ return {
187
+ email: v.string().email(),
188
+ password: v.string().min(8),
189
+ remember: v.boolean().optional(),
190
+ };
191
+ }
192
+ }
193
+ ```
194
+
195
+ Mount the form on a component:
196
+
197
+ ```typescript
198
+ export class LoginPage extends Component {
199
+ @expose form = new LoginForm();
200
+
201
+ @expose async login(): Promise<void> {
202
+ this.validate(this.form); // runs the form's rules
203
+
204
+ const ok = await Auth.attempt({
205
+ email: this.form.email,
206
+ password: this.form.password,
207
+ remember: this.form.remember,
208
+ });
209
+
210
+ if (!ok) {
211
+ this.addError("email", "These credentials do not match.");
212
+ return;
213
+ }
214
+
215
+ this.redirect("/dashboard");
216
+ }
217
+ }
218
+ ```
219
+
220
+ Bind fields with nested `value={this.form.email}`:
221
+
222
+ ```tsx
223
+ <form onSubmit={this.login} class="space-y-4">
224
+ <Field label="Email" error={this.errors.email}>
225
+ <input value={this.form.email} type="email" live class="input" />
226
+ </Field>
227
+
228
+ <Field label="Password" error={this.errors.password}>
229
+ <input value={this.form.password} type="password" class="input" />
230
+ </Field>
231
+
232
+ <label class="flex items-center gap-2">
233
+ <input type="checkbox" checked={this.form.remember} />
234
+ Remember me
235
+ </label>
236
+
237
+ <button type="submit" loadingAttr="disabled">
238
+ Sign in
239
+ </button>
240
+ </form>
241
+ ```
242
+
243
+ ### Form helpers
244
+
245
+ ```typescript
246
+ // Get all field values as a plain object
247
+ const data = this.form.data();
248
+ // { email: "alice@example.com", password: "…", remember: true }
249
+
250
+ // Fill the form from an existing record
251
+ await this.form.fill(post);
252
+
253
+ // Reset to defaults
254
+ this.form.reset();
255
+
256
+ // Reset specific fields only
257
+ this.form.reset("email", "password");
258
+
259
+ // Run the form's validation manually
260
+ this.form.validate();
261
+ ```
262
+
263
+ ### Why form objects?
264
+
265
+ - **Reuse**: the same `LoginForm` can be mounted on a `LoginModal` and a `LoginPage`.
266
+ - **Reset**: `this.form.reset()` restores all fields to defaults in one call.
267
+ - **Isolation**: errors land on the component's error bag, not on the form itself, so `<span error={this.errors.email} />` works unchanged.
268
+ - **Survival**: form class instances are re-created from the snapshot on each round-trip — the synthesizer handles it, so methods and defaults are always available.
269
+
270
+ ## Paginated results
271
+
272
+ See [Pagination](/docs/flow/pagination) for the full guide — `paginate()`, `Pagination` mixin, database pagination, infinite scroll, and cursor pagination.
273
+
274
+ Quick reference for in-memory pagination:
275
+
276
+ ```typescript
277
+ import { paginate } from "@zerotal/flow";
278
+
279
+ export class PostsPage extends Component {
280
+ @url page = 1;
281
+ @locked all: Post[] = [];
282
+
283
+ override async onMount() {
284
+ this.all = await Post.query().orderBy("created_at", "desc").get();
285
+ }
286
+
287
+ @expose goTo(n: number): void {
288
+ this.page = n;
289
+ }
290
+
291
+ override async render() {
292
+ const p = paginate(this.all, this.page, 10); // (items, page, perPage)
293
+
294
+ return (
295
+ <div>
296
+ <ul>
297
+ {p.data.map((post) => (
298
+ <li key={String(post.id)}>{post.title}</li>
299
+ ))}
300
+ </ul>
301
+
302
+ <p>
303
+ Showing {p.from}–{p.to} of {p.total}
304
+ </p>
305
+
306
+ <nav class="flex gap-1">
307
+ {p.elements().map((el) =>
308
+ el === "..." ? (
309
+ <span class="px-2">…</span>
310
+ ) : (
311
+ <button
312
+ onClick={() => this.goTo(el as number)}
313
+ class={el === p.page ? "font-bold underline" : ""}
314
+ >
315
+ {el}
316
+ </button>
317
+ ),
318
+ )}
319
+ </nav>
320
+ </div>
321
+ );
322
+ }
323
+ }
324
+ ```
325
+
326
+ **Paginator properties:**
327
+
328
+ | Property | Type | Description |
329
+ | ----------------- | --------------------- | ------------------------------------ |
330
+ | `data` | `T[]` | Items on the current page |
331
+ | `total` | `number` | Total item count across all pages |
332
+ | `page` | `number` | Current page number |
333
+ | `perPage` | `number` | Items per page |
334
+ | `lastPage` | `number` | Last page number |
335
+ | `from` | `number` | Index of the first item on this page |
336
+ | `to` | `number` | Index of the last item on this page |
337
+ | `onFirstPage` | `boolean` | True if on page 1 |
338
+ | `hasMorePages` | `boolean` | True if there are more pages |
339
+ | `elements(each?)` | `(number \| "...")[]` | Windowed page list with ellipsis |
340
+
341
+ ### Pagination mixin
342
+
343
+ Compose `Pagination` to get the page state and navigation methods automatically — no boilerplate:
344
+
345
+ ```tsx
346
+ import { Component, Pagination } from "@zerotal/flow";
347
+
348
+ export class PostsPage extends Component.using(Pagination) {
349
+ @locked all: Post[] = [];
350
+
351
+ override async onMount() {
352
+ this.all = await Post.query().orderBy("created_at", "desc").get();
353
+ }
354
+
355
+ override async render() {
356
+ const posts = await Post.paginate(10); // uses this.page automatically
357
+
358
+ return (
359
+ <div>
360
+ <ul>
361
+ {p.data.map((post) => (
362
+ <li key={String(post.id)}>{post.title}</li>
363
+ ))}
364
+ </ul>
365
+
366
+ <nav class="flex items-center gap-1">
367
+ <button onClick={this.previousPage} disabled={p.onFirstPage}>
368
+
369
+ </button>
370
+
371
+ {p.elements().map((el) =>
372
+ el === "..." ? (
373
+ <span class="px-2">…</span>
374
+ ) : (
375
+ <a href={`?page=${el}`} navigate class={el === p.page ? "font-bold" : ""}>
376
+ {el}
377
+ </a>
378
+ ),
379
+ )}
380
+
381
+ <button onClick={this.nextPage} disabled={!p.hasMorePages}>
382
+
383
+ </button>
384
+ </nav>
385
+
386
+ <p class="text-sm text-gray-500">
387
+ Showing {p.from}–{p.to} of {p.total}
388
+ </p>
389
+ </div>
390
+ );
391
+ }
392
+ }
393
+ ```
394
+
395
+ **`Pagination` adds:**
396
+
397
+ | Member | Type | Description |
398
+ | ---------------- | ---------------- | ------------------------------------------ |
399
+ | `page` | `@url number` | Current page, synced to `?page=` |
400
+ | `gotoPage(n)` | `@expose method` | Jump to a specific page |
401
+ | `resetPage()` | method | Reset to page 1 (call when filters change) |
402
+ | `nextPage()` | `@expose method` | Go to next page |
403
+ | `previousPage()` | `@expose method` | Go to previous page |
404
+
405
+ Reset the page when a filter changes to avoid showing an empty page:
406
+
407
+ ```typescript
408
+ @expose async applyFilter(status: string): Promise<void> {
409
+ this.filter = status;
410
+ this.resetPage(); // go back to page 1
411
+ }
412
+ ```
413
+
414
+ Compose with other mixins:
415
+
416
+ ```typescript
417
+ // `Sorting` here is a mixin you author yourself (see Layouts & Composition);
418
+ // `Pagination` is the one shipped by Flow.
419
+ export class PostsPage extends Component.using(Sorting, Pagination) {
420
+ // gets both pagination AND sorting for free
421
+ }
422
+ ```
423
+
424
+ ### Database-backed pagination
425
+
426
+ For database queries, skip the in-memory `paginate()` and use the ORM query builder directly. Combine with `@url page`:
427
+
428
+ ```typescript
429
+ export class PostsPage extends Component {
430
+ @url page = 1;
431
+ @locked posts: Post[] = [];
432
+ @locked total = 0;
433
+ @locked lastPage = 1;
434
+
435
+ override async onMount() {
436
+ await this.load();
437
+ }
438
+
439
+ @expose async load(): Promise<void> {
440
+ const result = await Post.query()
441
+ .where("status", "published")
442
+ .orderBy("created_at", "desc")
443
+ .paginate(15);
444
+
445
+ this.posts = result.data;
446
+ this.total = result.total;
447
+ this.lastPage = result.lastPage;
448
+ }
449
+
450
+ override async render() {
451
+ return (
452
+ <div>
453
+ <ul>
454
+ {this.posts.map((post) => (
455
+ <li key={String(post.id)}>{post.title}</li>
456
+ ))}
457
+ </ul>
458
+ <p>Page {this.page} of {this.lastPage} — {this.total} total</p>
459
+ </div>
460
+ );
461
+ }
462
+ }
463
+ ```
464
+
465
+ ## File uploads
466
+
467
+ Flow supports server-handled file uploads. Bind a file input with `flow:model`; the bytes are
468
+ uploaded over HTTP to `/__flow/upload`, stored on a temporary disk, and the bound property
469
+ becomes a `TemporaryUploadedFile`. In an action you call `.store()` to move it to permanent
470
+ storage.
471
+
472
+ > **Note** — Requires `zerotal/storage` configured (a default disk) and `APP_KEY` set (used to sign the
473
+ > temp-file reference). The upload endpoint requires an authenticated user (`ctx.user`).
474
+
475
+ ### Component
476
+
477
+ ```tsx
478
+ // app/flow/AvatarUploader.tsx
479
+ import { Component, expose } from "@zerotal/flow";
480
+ import type { TemporaryUploadedFile } from "@zerotal/flow";
481
+ import { Storage } from "zerotal/storage";
482
+
483
+ export class AvatarUploader extends Component {
484
+ @expose avatar: TemporaryUploadedFile | null = null; // single file
485
+ // @expose files: TemporaryUploadedFile[] = []; // for <input multiple>
486
+
487
+ @expose async save(): Promise<void> {
488
+ if (!this.avatar) {
489
+ this.flash("Choose a file first.", "warning");
490
+ return;
491
+ }
492
+ const path = await this.avatar.store("avatars", "public"); // → permanent disk, returns path
493
+ const url = Storage.disk("public").url(path);
494
+ // …persist `url` on your model…
495
+ this.avatar = null; // clear the temp ref
496
+ this.flash("Uploaded.", "success");
497
+ }
498
+
499
+ override async render() {
500
+ return (
501
+ <div>
502
+ <input type="file" flow:model="avatar" accept="image/*" />
503
+ <div id="bar" style="height:3px;width:0" />
504
+ <button onClick={this.save} disabled={!this.avatar}>
505
+ Save
506
+ </button>
507
+ <script
508
+ dangerouslySetInnerHTML={{
509
+ __html: `
510
+ addEventListener('flow:upload-progress', e => { document.getElementById('bar').style.width = e.detail.percent + '%'; });
511
+ addEventListener('flow:upload-finish', () => { document.getElementById('bar').style.width = '100%'; });
512
+ `,
513
+ }}
514
+ />
515
+ </div>
516
+ );
517
+ }
518
+ }
519
+ ```
520
+
521
+ ### The TemporaryUploadedFile object
522
+
523
+ - `name`, `mime`, `size`, `extension()`, `isImage()`
524
+ - `await store(directory, disk?, filename?)` → stored path (moves temp → permanent)
525
+ - `await bytes()` → `Uint8Array`
526
+ - `await temporaryUrl(ttlSeconds?)` → preview URL (signed/expiring where the driver supports it)
527
+
528
+ ### Client events
529
+
530
+ `flow:upload-start` · `flow:upload-progress` (`{key, name, percent}`) · `flow:upload-finish` ·
531
+ `flow:upload-error` (`{key, error}`).
532
+
533
+ ### Security
534
+
535
+ - The temp reference is HMAC-signed with `APP_KEY`; the server verifies it on `$set` and refuses
536
+ forged paths. Once in the snapshot it's covered by the snapshot HMAC.
537
+ - The endpoint enforces a 25 MB ceiling (apps should validate stricter — size/mime — in the
538
+ `save` action via the file's `size`/`mime`).
539
+ - Temp files (`flow-tmp/…`, random UUID names) are garbage-collected after 6h; `.store()`
540
+ removes the temp copy immediately.
541
+
542
+ ### Notes / future
543
+
544
+ - v1 proxies bytes through the server to the default disk. Direct-to-S3 presigned uploads and a
545
+ dedicated `temp` disk are natural follow-ups; the `TemporaryUploadedFile` API is designed to
546
+ absorb them without changing component code.
547
+
548
+ ## Next steps
549
+
550
+ - [Flow overview](/docs/flow) — the guide's front page and the rest of the sections.
551
+ - [Reference](/docs/flow/references) — every decorator, prop, and directive in one table.