@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.
- package/CHANGELOG.md +86 -2
- package/README.md +4 -2
- package/api-surface.md +2 -0
- package/docs/about.md +16 -16
- package/docs/admin/actions.md +9 -9
- package/docs/admin/auth.md +2 -2
- package/docs/admin/dashboard.md +5 -5
- package/docs/admin/extending-ui.md +12 -12
- package/docs/admin/extending.md +16 -7
- package/docs/admin/forms.md +4 -4
- package/docs/admin/index.md +2 -2
- package/docs/admin/operations.md +9 -9
- package/docs/admin/resources.md +3 -3
- package/docs/admin/structure.md +6 -6
- package/docs/admin/tables.md +8 -8
- package/docs/admin/testing.md +4 -4
- package/docs/ai.md +118 -11
- package/docs/application.md +12 -12
- package/docs/arch.md +25 -3
- package/docs/assets.md +11 -11
- package/docs/audit.md +13 -13
- package/docs/authentication.md +39 -39
- package/docs/authorization.md +13 -13
- package/docs/broadcasting/channels.md +2 -2
- package/docs/broadcasting/client.md +2 -2
- package/docs/broadcasting/events.md +9 -10
- package/docs/broadcasting/index.md +1 -1
- package/docs/broadcasting/testing.md +5 -5
- package/docs/cache.md +15 -13
- package/docs/carbon.md +35 -35
- package/docs/changelog.md +268 -1
- package/docs/client/index.md +13 -13
- package/docs/commands.md +36 -4
- package/docs/components.md +116 -115
- package/docs/config-system.md +4 -4
- package/docs/container.md +27 -27
- package/docs/context.md +27 -27
- package/docs/contributing.md +28 -0
- package/docs/controllers.md +10 -10
- package/docs/conventions.md +23 -15
- package/docs/cookies.md +3 -3
- package/docs/csrf.md +6 -6
- package/docs/database.md +22 -17
- package/docs/deployment.md +99 -2
- package/docs/devtools.md +16 -16
- package/docs/email-verification.md +8 -8
- package/docs/encryption.md +7 -7
- package/docs/errors.md +6 -6
- package/docs/events.md +12 -11
- package/docs/flow/components.md +32 -32
- package/docs/flow/decorators.md +18 -18
- package/docs/flow/events.md +16 -16
- package/docs/flow/forms.md +17 -17
- package/docs/flow/icons.md +5 -5
- package/docs/flow/index.md +21 -21
- package/docs/flow/layouts.md +99 -35
- package/docs/flow/lifecycle.md +16 -16
- package/docs/flow/models.md +9 -9
- package/docs/flow/pagination.md +8 -8
- package/docs/flow/performance.md +7 -7
- package/docs/flow/references.md +3 -3
- package/docs/flow/routing.md +26 -26
- package/docs/flow/testing.md +20 -20
- package/docs/getting-started.md +18 -13
- package/docs/health.md +17 -8
- package/docs/helpers.md +17 -17
- package/docs/i18n.md +10 -10
- package/docs/inertia/devtools.md +4 -4
- package/docs/inertia/index.md +6 -6
- package/docs/inertia/props.md +21 -21
- package/docs/inertia/rendering.md +9 -9
- package/docs/inertia/ssr.md +2 -2
- package/docs/lifecycle.md +2 -2
- package/docs/lock.md +11 -11
- package/docs/logger.md +12 -12
- package/docs/media.md +22 -22
- package/docs/middleware.md +17 -17
- package/docs/migrations.md +13 -13
- package/docs/monitor.md +6 -6
- package/docs/notifications.md +24 -24
- package/docs/orm/casts.md +14 -14
- package/docs/orm/factories.md +8 -8
- package/docs/orm/index.md +22 -20
- package/docs/orm/lifecycle.md +13 -13
- package/docs/orm/queries.md +28 -28
- package/docs/orm/relationships.md +16 -16
- package/docs/orm/serialization.md +11 -11
- package/docs/package-development.md +10 -10
- package/docs/pagination.md +14 -14
- package/docs/password-reset.md +6 -6
- package/docs/providers.md +11 -11
- package/docs/query-builder.md +18 -18
- package/docs/queue.md +14 -12
- package/docs/rate-limiting.md +6 -6
- package/docs/responses.md +14 -14
- package/docs/roles-and-2fa.md +9 -9
- package/docs/routing.md +44 -44
- package/docs/scaffolding.md +1 -1
- package/docs/scheduler.md +10 -10
- package/docs/seeding.md +6 -6
- package/docs/session.md +16 -14
- package/docs/social.md +14 -14
- package/docs/storage.md +25 -13
- package/docs/structure.md +2 -2
- package/docs/support-policy.md +23 -10
- package/docs/telemetry.md +12 -12
- package/docs/tenancy.md +34 -40
- package/docs/testing/browser.md +6 -6
- package/docs/testing/console.md +3 -3
- package/docs/testing/database.md +10 -10
- package/docs/testing/flow-browser.md +6 -6
- package/docs/testing/http.md +12 -12
- package/docs/testing/index.md +29 -3
- package/docs/testing/mocking.md +8 -8
- package/docs/upgrade.md +1 -1
- package/docs/validator.md +19 -19
- package/docs/view.md +14 -14
- package/package.json +4 -4
- package/src/config.ts +8 -0
- package/src/index.ts +25 -1
- package/src/install/ArchInstallCommand.ts +46 -3
- package/src/install/guidelines.ts +84 -0
- package/src/install/shape.ts +177 -0
- package/src/install/skills.ts +271 -0
- package/src/provider/ArchProvider.ts +148 -0
- package/src/tools/searchDocs.ts +7 -0
package/docs/flow/layouts.md
CHANGED
|
@@ -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";
|
package/docs/flow/lifecycle.md
CHANGED
|
@@ -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");
|
package/docs/flow/models.md
CHANGED
|
@@ -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
|
|
package/docs/flow/pagination.md
CHANGED
|
@@ -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[] = [];
|