@zerotal/arch 1.7.3 → 1.7.5

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 (127) hide show
  1. package/CHANGELOG.md +34 -2
  2. package/README.md +4 -2
  3. package/api-surface.md +4 -4
  4. package/docs/about.md +16 -16
  5. package/docs/admin/actions.md +9 -9
  6. package/docs/admin/auth.md +2 -2
  7. package/docs/admin/dashboard.md +5 -5
  8. package/docs/admin/extending-ui.md +12 -12
  9. package/docs/admin/extending.md +16 -7
  10. package/docs/admin/forms.md +4 -4
  11. package/docs/admin/index.md +2 -2
  12. package/docs/admin/operations.md +9 -9
  13. package/docs/admin/resources.md +3 -3
  14. package/docs/admin/structure.md +6 -6
  15. package/docs/admin/tables.md +8 -8
  16. package/docs/admin/testing.md +4 -4
  17. package/docs/ai.md +118 -11
  18. package/docs/application.md +12 -12
  19. package/docs/arch.md +25 -3
  20. package/docs/assets.md +11 -11
  21. package/docs/audit.md +13 -13
  22. package/docs/authentication.md +39 -39
  23. package/docs/authorization.md +13 -13
  24. package/docs/broadcasting/channels.md +2 -2
  25. package/docs/broadcasting/client.md +2 -2
  26. package/docs/broadcasting/events.md +9 -10
  27. package/docs/broadcasting/index.md +1 -1
  28. package/docs/broadcasting/testing.md +5 -5
  29. package/docs/cache.md +15 -13
  30. package/docs/carbon.md +35 -35
  31. package/docs/changelog.md +165 -1
  32. package/docs/client/index.md +277 -70
  33. package/docs/commands.md +4 -4
  34. package/docs/components.md +116 -115
  35. package/docs/config-system.md +4 -4
  36. package/docs/container.md +27 -27
  37. package/docs/context.md +27 -27
  38. package/docs/contributing.md +46 -0
  39. package/docs/controllers.md +10 -10
  40. package/docs/conventions.md +23 -15
  41. package/docs/cookies.md +3 -3
  42. package/docs/csrf.md +6 -6
  43. package/docs/database.md +22 -17
  44. package/docs/deployment.md +2 -2
  45. package/docs/devtools.md +16 -16
  46. package/docs/email-verification.md +8 -8
  47. package/docs/encryption.md +7 -7
  48. package/docs/errors.md +6 -6
  49. package/docs/events.md +12 -11
  50. package/docs/flow/components.md +32 -32
  51. package/docs/flow/decorators.md +18 -18
  52. package/docs/flow/events.md +16 -16
  53. package/docs/flow/forms.md +17 -17
  54. package/docs/flow/icons.md +199 -0
  55. package/docs/flow/index.md +20 -20
  56. package/docs/flow/layouts.md +35 -35
  57. package/docs/flow/lifecycle.md +16 -16
  58. package/docs/flow/models.md +9 -9
  59. package/docs/flow/pagination.md +8 -8
  60. package/docs/flow/performance.md +7 -7
  61. package/docs/flow/references.md +3 -3
  62. package/docs/flow/routing.md +26 -26
  63. package/docs/flow/testing.md +20 -20
  64. package/docs/getting-started.md +18 -13
  65. package/docs/health.md +17 -8
  66. package/docs/helpers.md +17 -17
  67. package/docs/i18n.md +10 -10
  68. package/docs/inertia/devtools.md +4 -4
  69. package/docs/inertia/index.md +12 -7
  70. package/docs/inertia/props.md +22 -22
  71. package/docs/inertia/rendering.md +86 -7
  72. package/docs/inertia/ssr.md +2 -2
  73. package/docs/lifecycle.md +2 -2
  74. package/docs/lock.md +11 -11
  75. package/docs/logger.md +12 -12
  76. package/docs/media.md +22 -22
  77. package/docs/middleware.md +17 -17
  78. package/docs/migrations.md +13 -13
  79. package/docs/monitor.md +6 -6
  80. package/docs/notifications.md +24 -24
  81. package/docs/orm/casts.md +14 -14
  82. package/docs/orm/factories.md +8 -8
  83. package/docs/orm/index.md +22 -20
  84. package/docs/orm/lifecycle.md +13 -13
  85. package/docs/orm/queries.md +28 -28
  86. package/docs/orm/relationships.md +16 -16
  87. package/docs/orm/serialization.md +11 -11
  88. package/docs/package-development.md +10 -10
  89. package/docs/pagination.md +14 -14
  90. package/docs/password-reset.md +6 -6
  91. package/docs/providers.md +11 -11
  92. package/docs/query-builder.md +18 -18
  93. package/docs/queue.md +14 -12
  94. package/docs/rate-limiting.md +6 -6
  95. package/docs/responses.md +14 -14
  96. package/docs/roles-and-2fa.md +9 -9
  97. package/docs/routing.md +44 -44
  98. package/docs/scaffolding.md +1 -1
  99. package/docs/scheduler.md +10 -10
  100. package/docs/seeding.md +6 -6
  101. package/docs/session.md +16 -14
  102. package/docs/social.md +14 -14
  103. package/docs/storage.md +25 -13
  104. package/docs/structure.md +2 -2
  105. package/docs/support-policy.md +36 -8
  106. package/docs/telemetry.md +12 -12
  107. package/docs/tenancy.md +34 -40
  108. package/docs/testing/browser.md +6 -6
  109. package/docs/testing/console.md +3 -3
  110. package/docs/testing/database.md +10 -10
  111. package/docs/testing/flow-browser.md +6 -6
  112. package/docs/testing/http.md +12 -12
  113. package/docs/testing/index.md +3 -3
  114. package/docs/testing/mocking.md +8 -8
  115. package/docs/upgrade.md +1 -1
  116. package/docs/validator.md +19 -19
  117. package/docs/view.md +14 -14
  118. package/package.json +4 -4
  119. package/src/index.ts +25 -1
  120. package/src/tools/searchDocs.ts +7 -0
  121. package/docs/client/auth.md +0 -113
  122. package/docs/client/errors.md +0 -139
  123. package/docs/client/files.md +0 -118
  124. package/docs/client/references.md +0 -58
  125. package/docs/client/requests.md +0 -131
  126. package/docs/client/resilience.md +0 -141
  127. package/docs/client/testing.md +0 -146
@@ -49,7 +49,7 @@ export class AppLayout extends Layout {
49
49
 
50
50
  Attach the layout to a page:
51
51
 
52
- ```tsx
52
+ ```tsx fragment
53
53
  export class DashboardPage extends Component {
54
54
  static layout = AppLayout;
55
55
 
@@ -69,7 +69,7 @@ The layout's `render(slot)` receives the page's HTML as `slot`. Multiple pages c
69
69
 
70
70
  `static layout = SomeLayout` is the class form. The JSX-native alternative — and the same convention the framework's React/Inertia pages use — is to override the `layout(page)` method and wrap the rendered page in **any JSX** you like. There is no separate `Layout` base class and no named-slot mechanism: a layout is just a component you wrap the page in, and its regions are ordinary **props**.
71
71
 
72
- ```tsx
72
+ ```tsx fragment
73
73
  import { Component } from "@zerotal/flow";
74
74
  import type { HtmlNode } from "@zerotal/flow";
75
75
  import { AppLayout } from "#app/layouts/AppLayout.tsx";
@@ -102,7 +102,7 @@ export class DashboardPage extends Component {
102
102
 
103
103
  `AppLayout` is an ordinary function component — nothing framework-specific:
104
104
 
105
- ```tsx
105
+ ```tsx fragment
106
106
  export function AppLayout(props: { title?: string; actions?: HtmlNode; children?: unknown }) {
107
107
  return (
108
108
  <div data-flow-layout="app" class="min-h-screen bg-gray-50">
@@ -127,7 +127,7 @@ Two things to know:
127
127
 
128
128
  `static head` injects content into the `<head>` element on the initial render. For per-page head content (title, meta), use `<Head>` inside the page's `render()` — it's hoisted into `<head>` on load and on every `navigate` visit:
129
129
 
130
- ```tsx
130
+ ```tsx fragment
131
131
  import { Head } from "@zerotal/flow";
132
132
 
133
133
  override async render() {
@@ -153,7 +153,7 @@ Both are valid on a `div` and apply to every descendant, so the _content_ is cor
153
153
  but the document still declares English to anything reading the root element, which is wrong for a
154
154
  screen reader announcing the page in the wrong voice:
155
155
 
156
- ```tsx
156
+ ```tsx fragment
157
157
  override render(slot: HtmlNode) {
158
158
  return <div lang={activeLocale()}>{slot}</div>;
159
159
  }
@@ -176,7 +176,7 @@ import { SectionOutlet } from "@zerotal/flow";
176
176
  </header>;
177
177
  ```
178
178
 
179
- ```tsx
179
+ ```tsx fragment
180
180
  // In any page — fill it
181
181
  import { SectionContent } from "@zerotal/flow";
182
182
 
@@ -188,7 +188,7 @@ import { SectionContent } from "@zerotal/flow";
188
188
  `<SectionContent>` renders nothing where it appears. Children of `<SectionOutlet>` are the default,
189
189
  used when no page published anything:
190
190
 
191
- ```tsx
191
+ ```tsx fragment
192
192
  <SectionOutlet name="toolbar">
193
193
  <span class="text-sm text-gray-500">No actions</span>
194
194
  </SectionOutlet>
@@ -259,7 +259,7 @@ export function Sorting<T extends Constructor<Component>>(Base: T) {
259
259
  }
260
260
  ```
261
261
 
262
- ```tsx
262
+ ```tsx fragment
263
263
  export class UsersPage extends Component.using(Sorting, Pagination) {}
264
264
  ```
265
265
 
@@ -269,7 +269,7 @@ export class UsersPage extends Component.using(Sorting, Pagination) {}
269
269
  an app-level base carry its own state and actions and still take mixins, without being flattened
270
270
  out of the prototype chain:
271
271
 
272
- ```tsx
272
+ ```tsx fragment
273
273
  abstract class AdminPage extends Component {
274
274
  @expose breadcrumb = "admin";
275
275
 
@@ -297,7 +297,7 @@ live on it.
297
297
  The composed class carries `using` itself, so composition can be built up in stages — useful when
298
298
  a shared base is defined in one file and extended in another:
299
299
 
300
- ```tsx
300
+ ```tsx fragment
301
301
  const AdminBase = Component.using(Pagination).using(Sorting);
302
302
  export class ReportsPage extends AdminBase.using(FileUploads) {}
303
303
  ```
@@ -311,7 +311,7 @@ export class ReportsPage extends AdminBase.using(FileUploads) {}
311
311
 
312
312
  Embed other `Component` subclasses as child components. Each child has its own isolated state, its own snapshot, and its own WebSocket update cycle. A parent re-render does not re-render existing children — their DOM and state are preserved (island architecture).
313
313
 
314
- ```tsx
314
+ ```tsx fragment
315
315
  import { StatsWidget } from "./StatsWidget.tsx";
316
316
  import { ActivityFeed } from "./ActivityFeed.tsx";
317
317
 
@@ -340,7 +340,7 @@ export class DashboardPage extends Component {
340
340
 
341
341
  Each prop the parent passes is assigned onto the same-named field before any lifecycle hook runs — the field's initialiser is the default:
342
342
 
343
- ```tsx
343
+ ```tsx fragment
344
344
  export class CounterWidget extends Component {
345
345
  @locked step: number = 1;
346
346
  @locked label: string = "Count";
@@ -369,7 +369,7 @@ Props that need to survive WebSocket round-trips must be `@locked` so they are i
369
369
 
370
370
  Where props pass **data** into a child, slots pass **markup**. A child component's plain children become its **default slot**; a `slots={{ … }}` prop supplies **named slots**. Inside the child, place each with `this.slot(name)` (or `this.slot()` for the default), and branch on `this.hasSlot(name)` to drop an optional wrapper entirely. This is the pattern for reusable shells — cards, modals, panels, page headers — where the container is fixed but the contents vary per use.
371
371
 
372
- ```tsx
372
+ ```tsx fragment
373
373
  // The reusable shell — header and footer are optional.
374
374
  export class Card extends Component {
375
375
  override async render() {
@@ -415,7 +415,7 @@ Two consequences worth knowing:
415
415
 
416
416
  Give every child rendered inside a `.map()` a `key` tied to the row's own identity:
417
417
 
418
- ```tsx
418
+ ```tsx fragment
419
419
  {
420
420
  this.settings.map((s) => <SettingRow key={`setting-${s.id}`} settingKey={s.key} />);
421
421
  }
@@ -433,7 +433,7 @@ Keys are sanitised to `[a-zA-Z0-9_-]`, so dots are stripped and `a.b` collides w
433
433
 
434
434
  ### Lazy, deferred, and streamed loading
435
435
 
436
- ```tsx
436
+ ```tsx fragment
437
437
  // Defer mount until the placeholder enters the viewport (intersection observer)
438
438
  <HeavyChart key="chart" lazy />
439
439
 
@@ -454,7 +454,7 @@ works before and without Alpine). See [Streaming the initial render](#streaming-
454
454
 
455
455
  Override `placeholder()` to customise the skeleton shown while a lazy component loads:
456
456
 
457
- ```tsx
457
+ ```tsx fragment
458
458
  export class HeavyChart extends Component {
459
459
  override placeholder() {
460
460
  return <div class="h-64 w-full rounded-xl bg-gray-200 animate-pulse" />;
@@ -472,7 +472,7 @@ export class HeavyChart extends Component {
472
472
 
473
473
  A `@locked` prop is frozen after mount. Mark a prop `@reactive` instead and the parent re-pushes its value whenever it changes, re-rendering the child — while the child keeps the rest of its own state intact:
474
474
 
475
- ```tsx
475
+ ```tsx fragment
476
476
  export class PriceTag extends Component {
477
477
  @reactive currency = "USD";
478
478
  @reactive amount = 0;
@@ -497,7 +497,7 @@ export class PriceTag extends Component {
497
497
 
498
498
  `@modelable` is a reactive prop that also syncs **back** to the parent. The parent property and the child prop stay in lock-step, so you can build reusable input/control components:
499
499
 
500
- ```tsx
500
+ ```tsx fragment
501
501
  export class StarRating extends Component {
502
502
  @modelable rating: number = 0; // two-way bound to parent
503
503
 
@@ -537,7 +537,7 @@ finishes. They solve different problems and do not interact.
537
537
  Mark a child `stream` and the page paints immediately with that child's placeholder; its real markup
538
538
  is appended to the same response as soon as it finishes rendering:
539
539
 
540
- ```tsx
540
+ ```tsx fragment
541
541
  override async render() {
542
542
  return (
543
543
  <div>
@@ -569,13 +569,13 @@ so `stream` degrades to an ordinary inline child render.
569
569
 
570
570
  Push content to the client mid-action — before the final patch — using `this.stream()`. Useful for LLM token streaming, long-running progress updates, or any content that takes time:
571
571
 
572
- ```tsx
572
+ ```tsx fragment
573
573
  {/* In the template: declare the stream target */}
574
574
  <div stream="answer" class="prose" />
575
575
  <div stream="status" class="text-sm text-gray-500" />
576
576
  ```
577
577
 
578
- ```typescript
578
+ ```typescript fragment
579
579
  @expose async generate(): Promise<void> {
580
580
  this.stream("status", "Generating…");
581
581
 
@@ -596,7 +596,7 @@ this.stream("output", freshContent, { replace: true });
596
596
 
597
597
  `this.stream()` is the low-level primitive: it pushes raw HTML into a `flow:stream` target, but that content isn't part of the snapshot, so you must _also_ write the accumulated result to a field for the final render, and there's no built-in cancellation. `@task` handles both. Mark an async method `@task` and just **write the field** — the framework streams it:
598
598
 
599
- ```tsx
599
+ ```tsx fragment
600
600
  import { Component, task, expose } from "@zerotal/flow";
601
601
 
602
602
  export class Chat extends Component {
@@ -612,7 +612,7 @@ export class Chat extends Component {
612
612
  }
613
613
  ```
614
614
 
615
- ```tsx
615
+ ```tsx fragment
616
616
  {/* Bind the streamed field REACTIVELY (text={…} → flow:text, or x-text) so each write updates
617
617
  this element live off the client store — no flow:stream element, no re-render per chunk. */}
618
618
  <button onClick={this.generate} loadingAttr="disabled">Generate</button>
@@ -632,7 +632,7 @@ This is the primitive for AI answers, build/deploy logs, and progress feeds —
632
632
 
633
633
  Middleware attached to a `Router.flow()` call runs on the initial HTTP `GET` **and** on every WebSocket update for that page. This keeps auth gates active across the entire session, not just at page load:
634
634
 
635
- ```typescript
635
+ ```typescript fragment
636
636
  import { Router } from "zerotal";
637
637
  import { RequireAuthMiddleware } from "#app/middleware/RequireAuth.ts";
638
638
  import { RequireAdminMiddleware } from "#app/middleware/RequireAdmin.ts";
@@ -657,7 +657,7 @@ See [Testing](/docs/flow/testing) for the full guide — mounting, calling actio
657
657
 
658
658
  ### Mounting a composed component
659
659
 
660
- ```typescript
660
+ ```typescript fragment
661
661
  import { FlowTest } from "@zerotal/flow/testing";
662
662
  import { CounterPage } from "../app/flow/CounterPage.tsx";
663
663
 
@@ -672,14 +672,14 @@ t.assertDontSee("Count: 1");
672
672
 
673
673
  Mount with initial props (seed state before `onMount`):
674
674
 
675
- ```typescript
675
+ ```typescript fragment
676
676
  const t = await FlowTest.mount(PostsPage, { page: 2, search: "TypeScript" });
677
677
  expect(t.page().search).toBe("TypeScript");
678
678
  ```
679
679
 
680
680
  ### Calling composed actions
681
681
 
682
- ```typescript
682
+ ```typescript fragment
683
683
  // call() drives: onBoot → onHydrate → action() → onUpdate → render → onDehydrate
684
684
  await t.call("increment");
685
685
  expect(t.page().count).toBe(1);
@@ -694,7 +694,7 @@ expect(t.page().count).toBe(42);
694
694
 
695
695
  Two ways to change a property between calls:
696
696
 
697
- ```typescript
697
+ ```typescript fragment
698
698
  // set() — direct assignment, no hooks fire
699
699
  await t.set("draft", "Hello world");
700
700
 
@@ -708,14 +708,14 @@ Use `set()` to seed state for a specific scenario. Use `update()` to test that y
708
708
 
709
709
  **HTML assertions:**
710
710
 
711
- ```typescript
711
+ ```typescript fragment
712
712
  t.assertSee("Published post"); // HTML contains this string
713
713
  t.assertDontSee("Error"); // HTML does NOT contain this string
714
714
  ```
715
715
 
716
716
  **Validation assertions:**
717
717
 
718
- ```typescript
718
+ ```typescript fragment
719
719
  await t.call("save");
720
720
  t.assertHasErrors("email"); // field has at least one error
721
721
  t.assertHasErrors("email", "required"); // error message contains "required"
@@ -724,14 +724,14 @@ t.assertNoErrors(); // no errors at all
724
724
 
725
725
  **Redirect assertions:**
726
726
 
727
- ```typescript
727
+ ```typescript fragment
728
728
  t.assertRedirectedTo("/dashboard");
729
729
  t.assertNotRedirected();
730
730
  ```
731
731
 
732
732
  **Flash assertions:**
733
733
 
734
- ```typescript
734
+ ```typescript fragment
735
735
  t.assertFlashed("success", "Saved."); // level + message substring
736
736
  t.assertFlashed("error"); // just check the level
737
737
  t.assertFlashed(undefined, "Something went"); // just check the message substring
@@ -739,13 +739,13 @@ t.assertFlashed(undefined, "Something went"); // just check the message substrin
739
739
 
740
740
  **Event assertions:**
741
741
 
742
- ```typescript
742
+ ```typescript fragment
743
743
  t.assertDispatched("post-created");
744
744
  ```
745
745
 
746
746
  ### Composed accessors
747
747
 
748
- ```typescript
748
+ ```typescript fragment
749
749
  t.page(); // the Component instance — inspect properties directly
750
750
  t.html(); // the rendered HTML string
751
751
  t.errors(); // current validation error bag: Record<string, string[]>
@@ -755,7 +755,7 @@ t.snapshot(); // the serialised snapshot
755
755
 
756
756
  ### Full test example
757
757
 
758
- ```typescript
758
+ ```typescript fragment
759
759
  import { describe, test, expect, beforeEach } from "bun:test";
760
760
  import { FlowTest } from "@zerotal/flow/testing";
761
761
  import { LoginPage } from "#app/flow/LoginPage.tsx";
@@ -46,7 +46,7 @@ GET /page (initial render) WebSocket action frame (subsequent)
46
46
 
47
47
  A child gets its data from its parent, not from the URL. Each prop lands on the field of the same name before `onBoot()` and `onMount()` run, so the field's initialiser is its default and a hook can use the value straight away:
48
48
 
49
- ```typescript
49
+ ```typescript fragment
50
50
  export class CounterWidget extends Component {
51
51
  @locked label: string = "Count"; // ← <CounterWidget label="Views" />
52
52
  @locked step: number = 1; // ← defaults to 1 when the parent omits it
@@ -76,7 +76,7 @@ A child receives the request `HttpContext` in `onBoot(ctx)` / `onMount(ctx)` lik
76
76
 
77
77
  Runs on **every** request: the initial `GET` and every WebSocket update. Use it for setup that must be fresh on every round-trip — resolving the authenticated user from context, initialising i18n, wiring up per-request services:
78
78
 
79
- ```typescript
79
+ ```typescript fragment
80
80
  import { request } from "zerotal";
81
81
 
82
82
  override async onBoot() {
@@ -96,7 +96,7 @@ Runs once on the initial `GET` render, then is skipped on all subsequent WebSock
96
96
 
97
97
  A page on a dynamic segment needs no code here for the record it is about. A field of the model's type is [filled from the segment](/docs/flow/routing#path-parameters) before `onMount()` runs, so the query, the id field, and the 404 all belong to the route:
98
98
 
99
- ```typescript
99
+ ```typescript fragment
100
100
  export class PostPage extends Component {
101
101
  @locked post!: Post; // /posts/:post — nothing to load
102
102
  }
@@ -104,7 +104,7 @@ export class PostPage extends Component {
104
104
 
105
105
  What `onMount()` is for is everything the URL does not carry. It receives the route `HttpContext` — the same argument a controller action gets — which is where the signed-in user lives, and `ctx.params` is still there for a segment no field claimed:
106
106
 
107
- ```typescript
107
+ ```typescript fragment
108
108
  override async onMount({ user }: HttpContext) {
109
109
  this.canEdit = user?.id === this.post.authorId;
110
110
  }
@@ -112,7 +112,7 @@ override async onMount({ user }: HttpContext) {
112
112
 
113
113
  The context is passed to `onBoot()` too, but only the initial `GET` populates `ctx.params` — see the [warning in Routing](/docs/flow/routing#path-parameters). The argument is optional because a component can also be mounted outside a request (in a test, for example).
114
114
 
115
- ```typescript
115
+ ```typescript fragment
116
116
  override async onMount() {
117
117
  const [posts, drafts] = await Promise.all([
118
118
  Post.query()
@@ -133,7 +133,7 @@ Lists and counts are what belongs here — the things no route resolved and no p
133
133
 
134
134
  To force `onMount()` to re-run during a WebSocket action — for example after creating a new record and wanting to reload the list — call `this.refresh()` inside the action:
135
135
 
136
- ```typescript
136
+ ```typescript fragment
137
137
  @expose async createPost(): Promise<void> {
138
138
  await Post.create({ title: this.title, body: this.body });
139
139
  this.title = "";
@@ -147,7 +147,7 @@ To force `onMount()` to re-run during a WebSocket action — for example after c
147
147
 
148
148
  Runs on every WebSocket round-trip, immediately after state is restored from the snapshot. Use it to re-derive transient or protected state that wasn't persisted in the snapshot:
149
149
 
150
- ```typescript
150
+ ```typescript fragment
151
151
  export class PostEditorPage extends Component {
152
152
  @expose post!: Post; // /posts/:post/edit — re-read from the row every round-trip
153
153
  @transient wordCount = 0; // NOT persisted — derived again each time
@@ -178,7 +178,7 @@ are not part of a re-read, and anything derived from them.
178
178
 
179
179
  Fires **before** a client-written property value is applied to the component. Throw to reject the write — the value is discarded, an error is added, and the component re-renders:
180
180
 
181
- ```typescript
181
+ ```typescript fragment
182
182
  override async onUpdating(prop: string, value: unknown, key?: string) {
183
183
  // Prevent role escalation
184
184
  if (prop === "role" && value === "super_admin") {
@@ -198,7 +198,7 @@ override async onUpdating(prop: string, value: unknown, key?: string) {
198
198
 
199
199
  Fires **after** a client-written property is applied. Use it to normalise values, enforce computed side-effects, or trigger cascading updates:
200
200
 
201
- ```typescript
201
+ ```typescript fragment
202
202
  override async onUpdated(prop: string, value: unknown) {
203
203
  if (prop === "categoryId") {
204
204
  // When the category changes, reload the subcategories
@@ -212,7 +212,7 @@ override async onUpdated(prop: string, value: unknown) {
212
212
 
213
213
  Instead of branching on `prop` inside `onUpdating`/`onUpdated`, define a per-property method named `onUpdating<PropName>` or `onUpdated<PropName>` (Pascal-cased). Flow calls it automatically and keeps the generic fallback as a catch-all:
214
214
 
215
- ```typescript
215
+ ```typescript fragment
216
216
  @expose username = "";
217
217
  @expose email = "";
218
218
  @expose tags: string[] = [];
@@ -243,7 +243,7 @@ The per-property form is cleaner and TypeScript-friendly: the parameter type mat
243
243
 
244
244
  Runs once after the invoked action completes, before the render cycle. Use it to apply cross-cutting logic that should happen after any action:
245
245
 
246
- ```typescript
246
+ ```typescript fragment
247
247
  override async onUpdate() {
248
248
  // Always log the current state to the audit trail after any action:
249
249
  await AuditLog.create({
@@ -260,7 +260,7 @@ Unlike `onUpdated` (which fires per property, before the action), `onUpdate()` f
260
260
 
261
261
  Runs immediately before `render()` on every request (initial and WebSocket). Use it for template-level setup that shouldn't be in `render()` itself — resolving shared view data, picking a layout variant, etc.:
262
262
 
263
- ```typescript
263
+ ```typescript fragment
264
264
  override async onRendering() {
265
265
  // Decide which layout variant to use based on the user's subscription
266
266
  if (this.user?.isPro) {
@@ -275,7 +275,7 @@ Avoid async database calls here unless truly necessary — `onMount()` and `onHy
275
275
 
276
276
  Receives the rendered HTML string. Use it to post-process the output, measure render time, or send the HTML to a cache:
277
277
 
278
- ```typescript
278
+ ```typescript fragment
279
279
  override async onRendered(html: string) {
280
280
  // Log very long renders for investigation
281
281
  if (html.length > 100_000) {
@@ -293,7 +293,7 @@ The `html` parameter is the raw HTML of this component only — not the full pag
293
293
 
294
294
  Runs just before the component state is serialised into the snapshot at the end of every request. Use it to strip sensitive or ephemeral state that shouldn't be persisted:
295
295
 
296
- ```typescript
296
+ ```typescript fragment
297
297
  override async onDehydrate() {
298
298
  // Never persist raw upload paths between round-trips
299
299
  this.tempUploadPath = null;
@@ -314,7 +314,7 @@ After `onDehydrate()`, the snapshot is signed and sent to the browser as an encr
314
314
 
315
315
  Called when an `@expose`d action throws an unhandled error. The default behaviour flashes the error message with level `"error"`. Override to log to an error tracker or display a custom message:
316
316
 
317
- ```typescript
317
+ ```typescript fragment
318
318
  override async onError(error: Error) {
319
319
  // Log to your error tracker
320
320
  await Sentry.captureException(error, {
@@ -331,7 +331,7 @@ override async onError(error: Error) {
331
331
 
332
332
  If you want some errors to propagate normally and only handle specific types:
333
333
 
334
- ```typescript
334
+ ```typescript fragment
335
335
  override async onError(error: Error) {
336
336
  if (error instanceof DatabaseConnectionError) {
337
337
  this.flash("Database is temporarily unavailable. Please try again.", "error");
@@ -28,7 +28,7 @@ binds to and how a model resolves by something other than its primary key.
28
28
 
29
29
  The other way a model arrives is from a parent that already has it, as a prop:
30
30
 
31
- ```tsx
31
+ ```tsx fragment
32
32
  <PostCard post={this.post} />
33
33
  ```
34
34
 
@@ -53,7 +53,7 @@ one it edits.** Both put the model on the client; only `@expose` accepts anythin
53
53
  The snapshot carries the model's **id** and the result of its `toJSON()` — the same
54
54
  serialisation your API responses use, honouring `visible`, `hidden` and `appends`.
55
55
 
56
- ```ts
56
+ ```ts fragment
57
57
  @table("users")
58
58
  export class User extends BaseModel {
59
59
  static fillable = ["name", "email", "password"];
@@ -98,7 +98,7 @@ server error. To refuse a value loudly instead, or to vet one before it lands, t
98
98
 
99
99
  Bind to a field of an `@expose`d model and it is two-way:
100
100
 
101
- ```tsx
101
+ ```tsx fragment
102
102
  export class EditProfile extends Component {
103
103
  @expose user!: User;
104
104
 
@@ -149,7 +149,7 @@ and fails whatever the field actually holds. Two ways round it:
149
149
  the difference: fillable because a user sets it, hidden because the stored hash must never
150
150
  reach the page.
151
151
 
152
- ```tsx
152
+ ```tsx fragment
153
153
  <input type="password" value={this.user.password} blur />
154
154
  ```
155
155
 
@@ -166,7 +166,7 @@ listed in the model's `hashable` — see [Password hashing](/docs/orm#password-h
166
166
  A relation that is loaded when the page renders travels with the model, through its own
167
167
  `toJSON()`. It does not survive the round-trip:
168
168
 
169
- ```tsx
169
+ ```tsx fragment
170
170
  override async onMount(): Promise<void> {
171
171
  await this.post.loadMissing(["author"]); // this.post came from the route
172
172
  }
@@ -178,7 +178,7 @@ throws the ORM's guard: `Relation "author" was accessed on Post without eager lo
178
178
 
179
179
  Load what an action needs, where it needs it:
180
180
 
181
- ```ts
181
+ ```ts fragment
182
182
  @expose async approve(): Promise<void> {
183
183
  await this.post.loadMissing(["author"]);
184
184
  this.post.approved = true;
@@ -192,7 +192,7 @@ When the page _displays_ the relation, load it once per round-trip in
192
192
  the case worth watching: a template reading `this.post.author.name` works on the first paint
193
193
  and throws on every interaction after it.
194
194
 
195
- ```ts
195
+ ```ts fragment
196
196
  override async onHydrate(): Promise<void> {
197
197
  await this.post.loadMissing(["author"]);
198
198
  }
@@ -221,7 +221,7 @@ deleted — or soft-deleted — while a page holds it makes the next interaction
221
221
  patched, and the browser console carries the error. So follow a delete with a navigation
222
222
  rather than leaving the prop pointing at a row that is gone:
223
223
 
224
- ```ts
224
+ ```ts fragment
225
225
  @expose async destroy(): Promise<void> {
226
226
  await this.post.delete();
227
227
  this.redirect("/posts");
@@ -232,7 +232,7 @@ rather than leaving the prop pointing at a row that is gone:
232
232
 
233
233
  An array of models is sent as ids and re-read with a single `whereIn` query:
234
234
 
235
- ```tsx
235
+ ```tsx fragment
236
236
  @locked posts: Post[] = [];
237
237
  ```
238
238
 
@@ -11,7 +11,7 @@ Paginate in the database with `Model.paginate(perPage)` — it returns the page
11
11
 
12
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
13
 
14
- ```typescript
14
+ ```typescript fragment
15
15
  import { paginate } from "@zerotal/flow";
16
16
 
17
17
  export class PostsPage extends Component {
@@ -89,7 +89,7 @@ export class PostsPage extends Component {
89
89
 
90
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
91
 
92
- ```tsx
92
+ ```tsx fragment
93
93
  import { Component, Pagination, Pager } from "@zerotal/flow";
94
94
 
95
95
  export class PostsPage extends Component.using(Pagination) {
@@ -137,7 +137,7 @@ Optional update hooks fire around a page change: define any of `updatingPage(pag
137
137
 
138
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
139
 
140
- ```typescript
140
+ ```typescript fragment
141
141
  @url search = "";
142
142
  @url status = "all";
143
143
 
@@ -187,7 +187,7 @@ For large datasets, avoid loading all rows in `onMount()`. Paginate in the datab
187
187
 
188
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
189
 
190
- ```tsx
190
+ ```tsx fragment
191
191
  export class PostsPage extends Component.using(Pagination) {
192
192
  override async render() {
193
193
  const posts = await Post.paginate(10); // this component's page
@@ -223,14 +223,14 @@ Outside a component, `paginate()` reads `?page=` from the query string — what
223
223
 
224
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
225
 
226
- ```typescript
226
+ ```typescript fragment
227
227
  const first = await Post.paginate(10, 1); // always page 1
228
228
  const invoices = await Invoice.paginate(10, undefined, "invoices"); // a named paginator
229
229
  ```
230
230
 
231
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
232
 
233
- ```typescript
233
+ ```typescript fragment
234
234
  export class PostsPage extends Component.using(Pagination) {
235
235
  @url search = "";
236
236
  @url status = "all";
@@ -291,7 +291,7 @@ The ORM's `.paginate()` issues two queries — a `COUNT(*)` for the total and a
291
291
 
292
292
  Use the `<InfiniteScroll>` component to load more pages as the user scrolls down, without explicit page navigation:
293
293
 
294
- ```tsx
294
+ ```tsx fragment
295
295
  import { InfiniteScroll } from "@zerotal/flow";
296
296
 
297
297
  export class FeedPage extends Component {
@@ -345,7 +345,7 @@ export class FeedPage extends Component {
345
345
 
346
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
347
 
348
- ```typescript
348
+ ```typescript fragment
349
349
  export class ActivityPage extends Component {
350
350
  @url cursor: string | null = null;
351
351
  @locked items: Activity[] = [];
@@ -74,7 +74,7 @@ paying for a navigation you could have paid for during the hover. Flow has a sma
74
74
 
75
75
  `<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:
76
76
 
77
- ```tsx
77
+ ```tsx fragment
78
78
  import { Skeleton } from "@zerotal/flow";
79
79
 
80
80
  <Skeleton height="1.5rem" width="60%" /> {/* one bar */}
@@ -84,7 +84,7 @@ import { Skeleton } from "@zerotal/flow";
84
84
 
85
85
  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):
86
86
 
87
- ```tsx
87
+ ```tsx fragment
88
88
  export class ChartWidget extends Component {
89
89
  override placeholder() {
90
90
  return <Skeleton height="12rem" rounded="0.75rem" />;
@@ -101,7 +101,7 @@ Tune the tone with the `--flow-skeleton-color` CSS variable; the pulse respects
101
101
 
102
102
  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:
103
103
 
104
- ```tsx
104
+ ```tsx fragment
105
105
  @expose liked = false;
106
106
  @expose confirmLike(): void { /* … persist … */ }
107
107
 
@@ -124,7 +124,7 @@ Optimistic UI for a scalar is easy (flip a bool, reconcile on the patch). A **li
124
124
 
125
125
  **`<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):
126
126
 
127
- ```tsx
127
+ ```tsx fragment
128
128
  import { For } from "@zerotal/flow";
129
129
 
130
130
  <For each={this.todos} keyBy="id">
@@ -141,7 +141,7 @@ The item template supports element structure, static attributes, `class`/`classN
141
141
 
142
142
  **`appendOptimistic` / `removeOptimistic` — instant add/remove with rollback.** Mutate the array optimistically, then dispatch the action that persists it:
143
143
 
144
- ```tsx
144
+ ```tsx fragment
145
145
  <button
146
146
  onClick={() => {
147
147
  $flow.appendOptimistic("todos", { id: `tmp-${Date.now()}`, text: this.draft });
@@ -170,7 +170,7 @@ mirror image for deletes.
170
170
 
171
171
  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:
172
172
 
173
- ```tsx
173
+ ```tsx fragment
174
174
  <a href="/posts" navigate hover>
175
175
  Posts
176
176
  </a>;
@@ -251,7 +251,7 @@ A multi-step form, a long editor, or any in-progress flow then resumes
251
251
  **exactly** — whether the user reloads the page, closes and reopens the tab, or
252
252
  switches device. Nothing is stored in the browser:
253
253
 
254
- ```tsx
254
+ ```tsx fragment
255
255
  export class Wizard extends Component {
256
256
  static durable = true; // or { ttl: "1h", scope: "user" | "session" }
257
257
 
@@ -129,7 +129,7 @@ So Flow re-derives instead. Any component left mid-action is refreshed from the
129
129
 
130
130
  The refresh is announced first, so you can say something rather than have the UI change under the user:
131
131
 
132
- ```ts
132
+ ```ts fragment
133
133
  document.addEventListener("flow:desync", (e) => {
134
134
  const { components } = (e as CustomEvent<{ components: string[] }>).detail;
135
135
  toast(`Reconnected — refreshing ${components.length} component(s).`);
@@ -156,7 +156,7 @@ Mark a container with `onSort` (the reorder action) and each child with `sortIte
156
156
  | `sortIgnore` | Exclude this child from dragging/reordering | `flow:sort:ignore` |
157
157
  | `sortGroup="tasks"` | Allow dragging between containers sharing the group name | `flow:sort:group` |
158
158
 
159
- ```tsx
159
+ ```tsx fragment
160
160
  @expose async reorder(key: string, index: number) {
161
161
  const moved = this.items.find((i) => String(i.id) === key);
162
162
  if (!moved) return;
@@ -272,7 +272,7 @@ A write syncs to the server after the expression finishes, unless the same expre
272
272
 
273
273
  `$flow.store` is app-wide UI state that shouldn't round-trip to the server — side-panel visibility, colour scheme, notification drawer, etc. Declare its shape with `defineStore(...)` at app start and type it by augmenting `FlowStore` (see [The global client store](/docs/flow#the-global-client-store)):
274
274
 
275
- ```tsx
275
+ ```tsx fragment
276
276
  <button onClick={() => ($flow.store.ui.sidebarOpen = true)}>Open sidebar</button>
277
277
  <aside show={$flow.store.ui.sidebarOpen}>Sidebar content</aside>
278
278
  ```