@zerotal/arch 1.7.4 → 1.8.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 (126) hide show
  1. package/CHANGELOG.md +86 -2
  2. package/README.md +4 -2
  3. package/api-surface.md +2 -0
  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 +268 -1
  32. package/docs/client/index.md +13 -13
  33. package/docs/commands.md +36 -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 +28 -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 +99 -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 +5 -5
  55. package/docs/flow/index.md +21 -21
  56. package/docs/flow/layouts.md +99 -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 +6 -6
  70. package/docs/inertia/props.md +21 -21
  71. package/docs/inertia/rendering.md +9 -9
  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 +23 -10
  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 +29 -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/config.ts +8 -0
  120. package/src/index.ts +25 -1
  121. package/src/install/ArchInstallCommand.ts +46 -3
  122. package/src/install/guidelines.ts +84 -0
  123. package/src/install/shape.ts +177 -0
  124. package/src/install/skills.ts +271 -0
  125. package/src/provider/ArchProvider.ts +148 -0
  126. package/src/tools/searchDocs.ts +7 -0
@@ -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";
@@ -365,11 +365,75 @@ export class CounterWidget extends Component {
365
365
 
366
366
  Props that need to survive WebSocket round-trips must be `@locked` so they are included in the snapshot. A `@locked` prop is set once at mount and stays fixed for the child's lifetime.
367
367
 
368
+ ### Static children
369
+
370
+ A child that will never receive an action does not need to be an island. Declare
371
+ `static interactive = false` and it is rendered as plain markup:
372
+
373
+ ```tsx fragment
374
+ export class SiteHeader extends Component {
375
+ static interactive = false;
376
+
377
+ override async render() {
378
+ return <header class="site-header">{/* … */}</header>;
379
+ }
380
+ }
381
+ ```
382
+
383
+ No snapshot, no state script, no entry in the client's component registry — and
384
+ nothing on the wire for it beyond the HTML itself. Use it for the parts of a page
385
+ that are structure rather than behaviour: headers, nav rails, footers, marketing
386
+ sections.
387
+
388
+ **A static child re-renders with its parent.** That is the difference that
389
+ matters. An interactive child is preserved across a parent update — its DOM and
390
+ state are left alone, which is the point of an island. A static child has no
391
+ state to preserve, so it is rendered fresh every time the parent renders, and
392
+ props passed to it are simply current.
393
+
394
+ It cannot be `lazy`, `defer` or `stream`: each of those waits for the client to
395
+ ask for the real render, and a static child never registers with the client to
396
+ do the asking. Passing one throws rather than leaving a placeholder that never
397
+ resolves.
398
+
399
+ Inside the component, `this.isInteractive` reports which mode it is in — useful
400
+ for leaving out something that only makes sense with a client attached.
401
+
402
+ ### A wholly static page opens no socket
403
+
404
+ `static interactive = false` works on a routed page as well as on a child, and the
405
+ page is the one that matters: a page is a component too, so a page whose children
406
+ are all static but which is interactive itself still registers with the client —
407
+ and still connects.
408
+
409
+ ```tsx fragment
410
+ export class ChangelogPage extends Component {
411
+ static interactive = false;
412
+
413
+ override async render() {
414
+ return <article>{/* … */}</article>;
415
+ }
416
+ }
417
+ ```
418
+
419
+ When nothing on the page registers, **no WebSocket is opened at all**. Not opened
420
+ and idle — never created. For a marketing page, a documentation article or a
421
+ rendered report, that is one connection per visitor that no longer exists on
422
+ either end.
423
+
424
+ The connection is made when it is needed rather than at boot, so a page that
425
+ becomes interactive later — an SPA navigation to one that is, a deferred child
426
+ arriving — connects at that moment. `<Link navigate>` needs nothing: it fetches
427
+ over HTTP.
428
+
429
+ A page with a single interactive component still connects, as before. One is
430
+ enough.
431
+
368
432
  ### Slots
369
433
 
370
434
  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
435
 
372
- ```tsx
436
+ ```tsx fragment
373
437
  // The reusable shell — header and footer are optional.
374
438
  export class Card extends Component {
375
439
  override async render() {
@@ -415,7 +479,7 @@ Two consequences worth knowing:
415
479
 
416
480
  Give every child rendered inside a `.map()` a `key` tied to the row's own identity:
417
481
 
418
- ```tsx
482
+ ```tsx fragment
419
483
  {
420
484
  this.settings.map((s) => <SettingRow key={`setting-${s.id}`} settingKey={s.key} />);
421
485
  }
@@ -433,7 +497,7 @@ Keys are sanitised to `[a-zA-Z0-9_-]`, so dots are stripped and `a.b` collides w
433
497
 
434
498
  ### Lazy, deferred, and streamed loading
435
499
 
436
- ```tsx
500
+ ```tsx fragment
437
501
  // Defer mount until the placeholder enters the viewport (intersection observer)
438
502
  <HeavyChart key="chart" lazy />
439
503
 
@@ -454,7 +518,7 @@ works before and without Alpine). See [Streaming the initial render](#streaming-
454
518
 
455
519
  Override `placeholder()` to customise the skeleton shown while a lazy component loads:
456
520
 
457
- ```tsx
521
+ ```tsx fragment
458
522
  export class HeavyChart extends Component {
459
523
  override placeholder() {
460
524
  return <div class="h-64 w-full rounded-xl bg-gray-200 animate-pulse" />;
@@ -472,7 +536,7 @@ export class HeavyChart extends Component {
472
536
 
473
537
  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
538
 
475
- ```tsx
539
+ ```tsx fragment
476
540
  export class PriceTag extends Component {
477
541
  @reactive currency = "USD";
478
542
  @reactive amount = 0;
@@ -497,7 +561,7 @@ export class PriceTag extends Component {
497
561
 
498
562
  `@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
563
 
500
- ```tsx
564
+ ```tsx fragment
501
565
  export class StarRating extends Component {
502
566
  @modelable rating: number = 0; // two-way bound to parent
503
567
 
@@ -537,7 +601,7 @@ finishes. They solve different problems and do not interact.
537
601
  Mark a child `stream` and the page paints immediately with that child's placeholder; its real markup
538
602
  is appended to the same response as soon as it finishes rendering:
539
603
 
540
- ```tsx
604
+ ```tsx fragment
541
605
  override async render() {
542
606
  return (
543
607
  <div>
@@ -569,13 +633,13 @@ so `stream` degrades to an ordinary inline child render.
569
633
 
570
634
  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
635
 
572
- ```tsx
636
+ ```tsx fragment
573
637
  {/* In the template: declare the stream target */}
574
638
  <div stream="answer" class="prose" />
575
639
  <div stream="status" class="text-sm text-gray-500" />
576
640
  ```
577
641
 
578
- ```typescript
642
+ ```typescript fragment
579
643
  @expose async generate(): Promise<void> {
580
644
  this.stream("status", "Generating…");
581
645
 
@@ -596,7 +660,7 @@ this.stream("output", freshContent, { replace: true });
596
660
 
597
661
  `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
662
 
599
- ```tsx
663
+ ```tsx fragment
600
664
  import { Component, task, expose } from "@zerotal/flow";
601
665
 
602
666
  export class Chat extends Component {
@@ -612,7 +676,7 @@ export class Chat extends Component {
612
676
  }
613
677
  ```
614
678
 
615
- ```tsx
679
+ ```tsx fragment
616
680
  {/* Bind the streamed field REACTIVELY (text={…} → flow:text, or x-text) so each write updates
617
681
  this element live off the client store — no flow:stream element, no re-render per chunk. */}
618
682
  <button onClick={this.generate} loadingAttr="disabled">Generate</button>
@@ -632,7 +696,7 @@ This is the primitive for AI answers, build/deploy logs, and progress feeds —
632
696
 
633
697
  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
698
 
635
- ```typescript
699
+ ```typescript fragment
636
700
  import { Router } from "zerotal";
637
701
  import { RequireAuthMiddleware } from "#app/middleware/RequireAuth.ts";
638
702
  import { RequireAdminMiddleware } from "#app/middleware/RequireAdmin.ts";
@@ -657,7 +721,7 @@ See [Testing](/docs/flow/testing) for the full guide — mounting, calling actio
657
721
 
658
722
  ### Mounting a composed component
659
723
 
660
- ```typescript
724
+ ```typescript fragment
661
725
  import { FlowTest } from "@zerotal/flow/testing";
662
726
  import { CounterPage } from "../app/flow/CounterPage.tsx";
663
727
 
@@ -672,14 +736,14 @@ t.assertDontSee("Count: 1");
672
736
 
673
737
  Mount with initial props (seed state before `onMount`):
674
738
 
675
- ```typescript
739
+ ```typescript fragment
676
740
  const t = await FlowTest.mount(PostsPage, { page: 2, search: "TypeScript" });
677
741
  expect(t.page().search).toBe("TypeScript");
678
742
  ```
679
743
 
680
744
  ### Calling composed actions
681
745
 
682
- ```typescript
746
+ ```typescript fragment
683
747
  // call() drives: onBoot → onHydrate → action() → onUpdate → render → onDehydrate
684
748
  await t.call("increment");
685
749
  expect(t.page().count).toBe(1);
@@ -694,7 +758,7 @@ expect(t.page().count).toBe(42);
694
758
 
695
759
  Two ways to change a property between calls:
696
760
 
697
- ```typescript
761
+ ```typescript fragment
698
762
  // set() — direct assignment, no hooks fire
699
763
  await t.set("draft", "Hello world");
700
764
 
@@ -708,14 +772,14 @@ Use `set()` to seed state for a specific scenario. Use `update()` to test that y
708
772
 
709
773
  **HTML assertions:**
710
774
 
711
- ```typescript
775
+ ```typescript fragment
712
776
  t.assertSee("Published post"); // HTML contains this string
713
777
  t.assertDontSee("Error"); // HTML does NOT contain this string
714
778
  ```
715
779
 
716
780
  **Validation assertions:**
717
781
 
718
- ```typescript
782
+ ```typescript fragment
719
783
  await t.call("save");
720
784
  t.assertHasErrors("email"); // field has at least one error
721
785
  t.assertHasErrors("email", "required"); // error message contains "required"
@@ -724,14 +788,14 @@ t.assertNoErrors(); // no errors at all
724
788
 
725
789
  **Redirect assertions:**
726
790
 
727
- ```typescript
791
+ ```typescript fragment
728
792
  t.assertRedirectedTo("/dashboard");
729
793
  t.assertNotRedirected();
730
794
  ```
731
795
 
732
796
  **Flash assertions:**
733
797
 
734
- ```typescript
798
+ ```typescript fragment
735
799
  t.assertFlashed("success", "Saved."); // level + message substring
736
800
  t.assertFlashed("error"); // just check the level
737
801
  t.assertFlashed(undefined, "Something went"); // just check the message substring
@@ -739,13 +803,13 @@ t.assertFlashed(undefined, "Something went"); // just check the message substrin
739
803
 
740
804
  **Event assertions:**
741
805
 
742
- ```typescript
806
+ ```typescript fragment
743
807
  t.assertDispatched("post-created");
744
808
  ```
745
809
 
746
810
  ### Composed accessors
747
811
 
748
- ```typescript
812
+ ```typescript fragment
749
813
  t.page(); // the Component instance — inspect properties directly
750
814
  t.html(); // the rendered HTML string
751
815
  t.errors(); // current validation error bag: Record<string, string[]>
@@ -755,7 +819,7 @@ t.snapshot(); // the serialised snapshot
755
819
 
756
820
  ### Full test example
757
821
 
758
- ```typescript
822
+ ```typescript fragment
759
823
  import { describe, test, expect, beforeEach } from "bun:test";
760
824
  import { FlowTest } from "@zerotal/flow/testing";
761
825
  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[] = [];