@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.
- package/CHANGELOG.md +34 -2
- package/README.md +4 -2
- package/api-surface.md +4 -4
- 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 +165 -1
- package/docs/client/index.md +277 -70
- package/docs/commands.md +4 -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 +46 -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 +2 -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 +199 -0
- package/docs/flow/index.md +20 -20
- package/docs/flow/layouts.md +35 -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 +12 -7
- package/docs/inertia/props.md +22 -22
- package/docs/inertia/rendering.md +86 -7
- 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 +36 -8
- 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 +3 -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/index.ts +25 -1
- package/src/tools/searchDocs.ts +7 -0
- package/docs/client/auth.md +0 -113
- package/docs/client/errors.md +0 -139
- package/docs/client/files.md +0 -118
- package/docs/client/references.md +0 -58
- package/docs/client/requests.md +0 -131
- package/docs/client/resilience.md +0 -141
- package/docs/client/testing.md +0 -146
package/docs/flow/routing.md
CHANGED
|
@@ -11,7 +11,7 @@ Flow pages are plain TypeScript classes. Register them as routes with `Router.fl
|
|
|
11
11
|
|
|
12
12
|
`Router.flow(path, PageClass, middleware?)` registers a `Component` subclass as a `GET` route. The third argument accepts an array of middleware classes that run on the **initial GET and on every WebSocket update** — this is Flow's persistent middleware model (no separate "attach middleware per WS frame" step required).
|
|
13
13
|
|
|
14
|
-
```typescript
|
|
14
|
+
```typescript fragment
|
|
15
15
|
// routes/web.ts
|
|
16
16
|
import { Router } from "zerotal";
|
|
17
17
|
import { DashboardPage } from "#app/flow/DashboardPage.tsx";
|
|
@@ -38,7 +38,7 @@ Router.flow("/admin", AdminPage, [RequireAuthMiddleware, RequireAdminMiddleware]
|
|
|
38
38
|
|
|
39
39
|
Chain `.name()` to give a route a name for reverse URL generation:
|
|
40
40
|
|
|
41
|
-
```typescript
|
|
41
|
+
```typescript fragment
|
|
42
42
|
Router.flow("/dashboard", DashboardPage, [RequireAuthMiddleware]).name("dashboard");
|
|
43
43
|
Router.flow("/posts", PostsPage).name("posts.index");
|
|
44
44
|
Router.flow("/posts/:slug", PostDetailPage).name("posts.show");
|
|
@@ -48,7 +48,7 @@ Router.flow("/posts/:slug", PostDetailPage).name("posts.show");
|
|
|
48
48
|
|
|
49
49
|
Use `Router.group()` to share a prefix and/or middleware across several Flow routes:
|
|
50
50
|
|
|
51
|
-
```typescript
|
|
51
|
+
```typescript fragment
|
|
52
52
|
Router.group({ prefix: "/app", middleware: [RequireAuthMiddleware] }, () => {
|
|
53
53
|
Router.flow("/dashboard", DashboardPage); // /app/dashboard
|
|
54
54
|
Router.flow("/profile", ProfilePage); // /app/profile
|
|
@@ -71,7 +71,7 @@ Middleware declared on the group is persistent: it re-runs on every WebSocket up
|
|
|
71
71
|
|
|
72
72
|
When you call `.fileBasedRouting()` on `Application`, the framework scans the given directory and auto-registers any file that exports a `Component` subclass. No import required in a route file.
|
|
73
73
|
|
|
74
|
-
```typescript
|
|
74
|
+
```typescript fragment
|
|
75
75
|
// bootstrap/app.ts
|
|
76
76
|
import { Application, basePath } from "zerotal";
|
|
77
77
|
import providers from "./providers.ts";
|
|
@@ -114,7 +114,7 @@ app/flow/
|
|
|
114
114
|
|
|
115
115
|
Export a `middleware` array from a file-route to attach middleware to that specific page:
|
|
116
116
|
|
|
117
|
-
```typescript
|
|
117
|
+
```typescript fragment
|
|
118
118
|
// app/flow/admin/DashboardPage.tsx
|
|
119
119
|
import { RequireAuthMiddleware } from "#app/middleware/RequireAuth.ts";
|
|
120
120
|
import { RequireAdminMiddleware } from "#app/middleware/RequireAdmin.ts";
|
|
@@ -128,7 +128,7 @@ export class DashboardPage extends Component {
|
|
|
128
128
|
|
|
129
129
|
Or use a `_middleware.ts` file in a directory to apply middleware to every file in that directory:
|
|
130
130
|
|
|
131
|
-
```typescript
|
|
131
|
+
```typescript fragment
|
|
132
132
|
// app/flow/admin/_middleware.ts
|
|
133
133
|
import { RequireAuthMiddleware } from "#app/middleware/RequireAuth.ts";
|
|
134
134
|
import { RequireAdminMiddleware } from "#app/middleware/RequireAdmin.ts";
|
|
@@ -142,7 +142,7 @@ export const middleware = [RequireAuthMiddleware, RequireAdminMiddleware];
|
|
|
142
142
|
|
|
143
143
|
For query string parameters (`/posts?page=2&search=typescript`), use the `@url` decorator. The field is filled from the URL on the first render, and stays in sync as the value changes:
|
|
144
144
|
|
|
145
|
-
```typescript
|
|
145
|
+
```typescript fragment
|
|
146
146
|
@url page: number = 1;
|
|
147
147
|
@url search: string = "";
|
|
148
148
|
@url status: string = "all";
|
|
@@ -158,7 +158,7 @@ For query string parameters (`/posts?page=2&search=typescript`), use the `@url`
|
|
|
158
158
|
|
|
159
159
|
Where `@url` syncs a reactive prop **to** the URL, `this.currentUrl()` and `this.navigateCurrent()` go the other way — they **build** a URL from the one you're on with some query params changed, for filter links and instant filtering. Both are client-only helpers (the compiler rewrites them to the client runtime), so they update without a server round-trip to start.
|
|
160
160
|
|
|
161
|
-
```typescript
|
|
161
|
+
```typescript fragment
|
|
162
162
|
// Build a URL from the current one — returns a string, does NOT navigate.
|
|
163
163
|
this.currentUrl({ query: { page: 3 } });
|
|
164
164
|
// /posts?search=ts&page=2 → /posts?search=ts&page=3
|
|
@@ -175,7 +175,7 @@ The merge rules:
|
|
|
175
175
|
|
|
176
176
|
**Use `currentUrl()` in a binding** — an attribute value or a text child. The compiler turns it into a reactive client binding, so the link always reflects the current URL:
|
|
177
177
|
|
|
178
|
-
```tsx
|
|
178
|
+
```tsx fragment
|
|
179
179
|
// Pagination links that preserve the active search / filters
|
|
180
180
|
<a href={this.currentUrl({ query: { page: this.page - 1 } })} flow:navigate>Previous</a>
|
|
181
181
|
<a href={this.currentUrl({ query: { page: this.page + 1 } })} flow:navigate>Next</a>
|
|
@@ -183,7 +183,7 @@ The merge rules:
|
|
|
183
183
|
|
|
184
184
|
**Use `navigateCurrent()` in a handler** — perfect for instant filters that navigate as the user picks:
|
|
185
185
|
|
|
186
|
-
```tsx
|
|
186
|
+
```tsx fragment
|
|
187
187
|
<select onChange={(e) => this.navigateCurrent({ query: { status: e.target.value || null } })}>
|
|
188
188
|
<option value="">All</option>
|
|
189
189
|
<option value="active">Active</option>
|
|
@@ -199,7 +199,7 @@ down is the case where that's wrong — the user is looking at the control they
|
|
|
199
199
|
just changed, and the results move out from under them. Pass `preserveScroll` to
|
|
200
200
|
leave the viewport where it is:
|
|
201
201
|
|
|
202
|
-
```tsx
|
|
202
|
+
```tsx fragment
|
|
203
203
|
<select
|
|
204
204
|
onChange={(e) =>
|
|
205
205
|
this.navigateCurrent({ query: { status: e.target.value || null }, preserveScroll: true })
|
|
@@ -217,12 +217,12 @@ Pagination is the opposite case: page 2 should start at the top, so leave it off
|
|
|
217
217
|
|
|
218
218
|
Name a field after the segment and it arrives filled. `/posts/:post` names the `Post` model, so the page receives the loaded record — no query, and no lookup code:
|
|
219
219
|
|
|
220
|
-
```typescript
|
|
220
|
+
```typescript fragment
|
|
221
221
|
// routes/web.ts — nothing to declare; :post is a Post
|
|
222
222
|
Router.flow("/posts/:post", PostDetailPage);
|
|
223
223
|
```
|
|
224
224
|
|
|
225
|
-
```typescript
|
|
225
|
+
```typescript fragment
|
|
226
226
|
export class PostDetailPage extends Component {
|
|
227
227
|
@locked post!: Post; // :post — the record, already loaded
|
|
228
228
|
|
|
@@ -252,7 +252,7 @@ Only `@locked` and `@expose` fields are filled, and only from segments the route
|
|
|
252
252
|
|
|
253
253
|
When the field's name differs from the segment, `@param` says where it comes from — either the segment's name, or the model:
|
|
254
254
|
|
|
255
|
-
```typescript
|
|
255
|
+
```typescript fragment
|
|
256
256
|
export class PostDetailPage extends Component {
|
|
257
257
|
@locked @param(Post) article!: Post; // whichever segment resolved to a Post
|
|
258
258
|
@locked @param("post") alsoArticle!: Post; // or name the segment
|
|
@@ -266,7 +266,7 @@ Passing the model is the sturdier of the two — the field says what it wants an
|
|
|
266
266
|
|
|
267
267
|
`onBoot()` and `onMount()` also receive the request itself — the same `HttpContext` a controller action gets — for anything the URL doesn't carry. It types `ctx.params` only, so the signed-in user is `{ user }`, not `params.user`:
|
|
268
268
|
|
|
269
|
-
```typescript
|
|
269
|
+
```typescript fragment
|
|
270
270
|
override async onMount({ user }: HttpContext) {
|
|
271
271
|
this.canEdit = user?.id === this.post.authorId;
|
|
272
272
|
}
|
|
@@ -278,14 +278,14 @@ The argument is optional, because a component can also be created outside a requ
|
|
|
278
278
|
|
|
279
279
|
A URL segment fills the **page**. `/posts/:post` gives the page its `post`; the components inside it get nothing from the URL, even if one of their fields happens to share the segment's name. If a child needs the post, the page hands it over:
|
|
280
280
|
|
|
281
|
-
```tsx
|
|
281
|
+
```tsx fragment
|
|
282
282
|
// in the page's render()
|
|
283
283
|
<PostCard post={this.post} compact />
|
|
284
284
|
```
|
|
285
285
|
|
|
286
286
|
A prop lands on the field of the same name, before any hook runs:
|
|
287
287
|
|
|
288
|
-
```typescript
|
|
288
|
+
```typescript fragment
|
|
289
289
|
export class PostCard extends Component {
|
|
290
290
|
@locked post!: Post; // required — no default
|
|
291
291
|
@locked compact = false; // optional — false when the page omits it
|
|
@@ -309,7 +309,7 @@ Mark props `@locked` (or `@expose`) so their values survive round-trips in the c
|
|
|
309
309
|
|
|
310
310
|
Coerce string params to numbers with `ctx.integer()`:
|
|
311
311
|
|
|
312
|
-
```typescript
|
|
312
|
+
```typescript fragment
|
|
313
313
|
override async onMount(ctx: HttpContext) {
|
|
314
314
|
this.userId = ctx.integer("id") ?? 0;
|
|
315
315
|
}
|
|
@@ -343,7 +343,7 @@ ctx.ip(); // string | null — client IP
|
|
|
343
343
|
|
|
344
344
|
`@session` binds a field to a session key, so its value survives a browser refresh. Reads and writes go straight to the session; nothing is kept in the component's snapshot, so the browser never sees the value:
|
|
345
345
|
|
|
346
|
-
```typescript
|
|
346
|
+
```typescript fragment
|
|
347
347
|
@session preferredTheme: string = "light"; // the session's `preferredTheme`
|
|
348
348
|
@session lastVisitedTab: string = "overview";
|
|
349
349
|
|
|
@@ -359,7 +359,7 @@ Requires `SessionMiddleware` on the route. See [Decorators](/docs/flow/decorator
|
|
|
359
359
|
|
|
360
360
|
For values that aren't a field on this component — a cart, a flash bag, anything you set elsewhere — use the [`Session`](/docs/session) facade. It resolves the in-flight request's session, so it works in any hook or action:
|
|
361
361
|
|
|
362
|
-
```typescript
|
|
362
|
+
```typescript fragment
|
|
363
363
|
import { Session } from "@zerotal/session";
|
|
364
364
|
|
|
365
365
|
override async onMount() {
|
|
@@ -381,7 +381,7 @@ Reach for `@session` when the value is a field on the component; reach for the f
|
|
|
381
381
|
|
|
382
382
|
When `AuthMiddleware` (or your `RequireAuthMiddleware`) runs, it populates `ctx.user`. Access it via `request()`:
|
|
383
383
|
|
|
384
|
-
```typescript
|
|
384
|
+
```typescript fragment
|
|
385
385
|
import { request } from "zerotal";
|
|
386
386
|
|
|
387
387
|
export class ProfilePage extends Component {
|
|
@@ -410,7 +410,7 @@ Because `onBoot()` runs on every request (initial GET and WebSocket), the auth c
|
|
|
410
410
|
|
|
411
411
|
Declare static properties on the page class to control the document `<title>`, inject `<head>` content, and attach a layout:
|
|
412
412
|
|
|
413
|
-
```typescript
|
|
413
|
+
```typescript fragment
|
|
414
414
|
export class DashboardPage extends Component {
|
|
415
415
|
// Sets <title>Dashboard</title> on the initial render
|
|
416
416
|
static title = "Dashboard";
|
|
@@ -430,7 +430,7 @@ export class DashboardPage extends Component {
|
|
|
430
430
|
|
|
431
431
|
`static title` takes a string, or a function of the component:
|
|
432
432
|
|
|
433
|
-
```typescript
|
|
433
|
+
```typescript fragment
|
|
434
434
|
static title = "Posts";
|
|
435
435
|
static title = (c: PostPage) => `${c.post?.title ?? "Loading"} — My App`;
|
|
436
436
|
```
|
|
@@ -438,7 +438,7 @@ static title = (c: PostPage) => `${c.post?.title ?? "Loading"} — My App`;
|
|
|
438
438
|
The function form is resolved on the server for every render and every patch, so a title
|
|
439
439
|
that depends on state follows it without an action doing anything:
|
|
440
440
|
|
|
441
|
-
```typescript
|
|
441
|
+
```typescript fragment
|
|
442
442
|
@expose async loadPost(slug: string): Promise<void> {
|
|
443
443
|
this.post = await Post.where("slug", slug).firstOrFail();
|
|
444
444
|
// the title updates with it — nothing else to call
|
|
@@ -449,7 +449,7 @@ Only the resolved string is sent to the browser; the function stays on the serve
|
|
|
449
449
|
|
|
450
450
|
For per-render `<head>` content (meta tags, OG tags), use `<Head>` inside `render()`:
|
|
451
451
|
|
|
452
|
-
```tsx
|
|
452
|
+
```tsx fragment
|
|
453
453
|
import { Head } from "@zerotal/flow";
|
|
454
454
|
|
|
455
455
|
override async render() {
|
|
@@ -470,7 +470,7 @@ override async render() {
|
|
|
470
470
|
|
|
471
471
|
A fully wired page with a dynamic route param, auth, session, query string, and layout:
|
|
472
472
|
|
|
473
|
-
```typescript
|
|
473
|
+
```typescript fragment
|
|
474
474
|
import { Component, expose, locked, url, session } from "@zerotal/flow";
|
|
475
475
|
import { request } from "zerotal";
|
|
476
476
|
import { RequireAuthMiddleware } from "#app/middleware/RequireAuth.ts";
|
package/docs/flow/testing.md
CHANGED
|
@@ -13,7 +13,7 @@ Import it from `@zerotal/flow/testing`.
|
|
|
13
13
|
|
|
14
14
|
`FlowTest.mount()` drives the **initial GET** cycle: `onBoot → onMount → onRendering → render → onRendered → onDehydrate`. It returns a test handle you can use for assertions and further interactions:
|
|
15
15
|
|
|
16
|
-
```typescript
|
|
16
|
+
```typescript fragment
|
|
17
17
|
import { FlowTest } from "@zerotal/flow/testing";
|
|
18
18
|
import { CounterPage } from "#app/flow/CounterPage.tsx";
|
|
19
19
|
|
|
@@ -29,7 +29,7 @@ t.assertDontSee("Count: 1");
|
|
|
29
29
|
|
|
30
30
|
Pass initial props to seed state before `onMount()` runs. Props are applied to the component instance before the lifecycle starts:
|
|
31
31
|
|
|
32
|
-
```typescript
|
|
32
|
+
```typescript fragment
|
|
33
33
|
const t = await FlowTest.mount(PostsPage, { page: 2, search: "TypeScript" });
|
|
34
34
|
|
|
35
35
|
expect(t.page().search).toBe("TypeScript");
|
|
@@ -43,7 +43,7 @@ This is equivalent to the component being navigated to with `?page=2&search=Type
|
|
|
43
43
|
|
|
44
44
|
`t.call(method, ...args)` drives a **WebSocket action frame**: `onBoot → onHydrate → [onUpdating/onUpdated] → action → onUpdate → onRendering → render → onRendered → onDehydrate`.
|
|
45
45
|
|
|
46
|
-
```typescript
|
|
46
|
+
```typescript fragment
|
|
47
47
|
await t.call("increment");
|
|
48
48
|
expect(t.page().count).toBe(1);
|
|
49
49
|
t.assertSee("Count: 1");
|
|
@@ -67,7 +67,7 @@ Any other error is passed to `onError()` — as in production — and then **ret
|
|
|
67
67
|
|
|
68
68
|
When the error path is what you're testing, opt in with `tolerateErrors()`:
|
|
69
69
|
|
|
70
|
-
```typescript
|
|
70
|
+
```typescript fragment
|
|
71
71
|
const t = (await FlowTest.mount(CheckoutPage)).tolerateErrors();
|
|
72
72
|
await t.call("submit");
|
|
73
73
|
|
|
@@ -79,7 +79,7 @@ t.assertFlashed("error", "Payment failed");
|
|
|
79
79
|
|
|
80
80
|
Three methods change a property between calls:
|
|
81
81
|
|
|
82
|
-
```typescript
|
|
82
|
+
```typescript fragment
|
|
83
83
|
// set() — direct assignment, then re-renders; no hooks fire
|
|
84
84
|
await t.set("draft", "Hello world");
|
|
85
85
|
await t.set("page", 3);
|
|
@@ -98,7 +98,7 @@ await t.render(); // one render for both
|
|
|
98
98
|
|
|
99
99
|
Use `set()` to put the component in a specific state for a test scenario. Use `update()` when you're testing that `onUpdating`/`onUpdated` hooks run correctly:
|
|
100
100
|
|
|
101
|
-
```typescript
|
|
101
|
+
```typescript fragment
|
|
102
102
|
// Test that onUpdatedUsername normalises to lowercase
|
|
103
103
|
const t = await FlowTest.mount(ProfilePage);
|
|
104
104
|
await t.update("username", "ALICE");
|
|
@@ -108,7 +108,7 @@ expect(t.page().username).toBe("alice"); // hook lowercased it
|
|
|
108
108
|
|
|
109
109
|
## HTML assertions
|
|
110
110
|
|
|
111
|
-
```typescript
|
|
111
|
+
```typescript fragment
|
|
112
112
|
t.assertSee("Published post"); // rendered HTML contains this string
|
|
113
113
|
t.assertDontSee("Error"); // rendered HTML does NOT contain this string
|
|
114
114
|
t.assertSee("<h1>Dashboard</h1>"); // can match HTML tags too
|
|
@@ -118,7 +118,7 @@ Both methods check `t.html()` — the raw rendered HTML string for this componen
|
|
|
118
118
|
|
|
119
119
|
## Validation assertions
|
|
120
120
|
|
|
121
|
-
```typescript
|
|
121
|
+
```typescript fragment
|
|
122
122
|
await t.call("register"); // trigger a validation action
|
|
123
123
|
|
|
124
124
|
t.assertHasErrors("email"); // field has at least one error
|
|
@@ -131,7 +131,7 @@ t.assertNoErrors(); // no validation errors at all
|
|
|
131
131
|
|
|
132
132
|
## Redirect assertions
|
|
133
133
|
|
|
134
|
-
```typescript
|
|
134
|
+
```typescript fragment
|
|
135
135
|
await t.call("login");
|
|
136
136
|
|
|
137
137
|
t.assertRedirectedTo("/dashboard"); // last action redirected to this URL
|
|
@@ -140,7 +140,7 @@ t.assertNotRedirected(); // last action did NOT redirect
|
|
|
140
140
|
|
|
141
141
|
## Flash assertions
|
|
142
142
|
|
|
143
|
-
```typescript
|
|
143
|
+
```typescript fragment
|
|
144
144
|
await t.call("save");
|
|
145
145
|
|
|
146
146
|
t.assertFlashed("success", "Saved."); // level + message substring
|
|
@@ -151,7 +151,7 @@ t.assertFlashed(); // any flash was emitted
|
|
|
151
151
|
|
|
152
152
|
## Event assertions
|
|
153
153
|
|
|
154
|
-
```typescript
|
|
154
|
+
```typescript fragment
|
|
155
155
|
await t.call("createPost");
|
|
156
156
|
|
|
157
157
|
t.assertDispatched("post-created"); // event was dispatched
|
|
@@ -160,7 +160,7 @@ t.assertDispatched("post-created", { id: 1 }); // event was dispatched with this
|
|
|
160
160
|
|
|
161
161
|
## Accessors
|
|
162
162
|
|
|
163
|
-
```typescript
|
|
163
|
+
```typescript fragment
|
|
164
164
|
t.page(); // the Component instance — inspect properties and call methods directly
|
|
165
165
|
t.html(); // the rendered HTML string from the last render
|
|
166
166
|
t.errors(); // current error bag: Record<string, string[]>
|
|
@@ -170,7 +170,7 @@ t.snapshot(); // the serialised snapshot blob
|
|
|
170
170
|
|
|
171
171
|
`t.page()` gives you the live component instance, so you can read any property:
|
|
172
172
|
|
|
173
|
-
```typescript
|
|
173
|
+
```typescript fragment
|
|
174
174
|
const page = t.page();
|
|
175
175
|
expect(page.posts.length).toBe(10);
|
|
176
176
|
expect(page.user?.email).toBe("alice@example.com");
|
|
@@ -191,7 +191,7 @@ That covers most actions on any page behind a sign-in, so open the scope yoursel
|
|
|
191
191
|
rather than an object literal cast to the type — it carries a real `Request`, which matters as soon
|
|
192
192
|
as anything downstream reads a header (an audited model records the actor's IP, for one):
|
|
193
193
|
|
|
194
|
-
```typescript
|
|
194
|
+
```typescript fragment
|
|
195
195
|
import { RequestContext, HttpContext } from "@zerotal/core";
|
|
196
196
|
|
|
197
197
|
function asUser<T>(user: User | null, fn: () => Promise<T>): Promise<T> {
|
|
@@ -213,7 +213,7 @@ never makes a request to send it on.
|
|
|
213
213
|
|
|
214
214
|
`FlowTest` does not set up or tear down a database — use your test suite's standard database helpers. With Bun, wrap tests in a transaction that rolls back after each test for full isolation:
|
|
215
215
|
|
|
216
|
-
```typescript
|
|
216
|
+
```typescript fragment
|
|
217
217
|
// tests/flow/PostsPage.test.ts
|
|
218
218
|
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
219
219
|
import { FlowTest } from "@zerotal/flow/testing";
|
|
@@ -250,7 +250,7 @@ describe("PostsPage", () => {
|
|
|
250
250
|
|
|
251
251
|
A complete example covering the common scenarios for a login page:
|
|
252
252
|
|
|
253
|
-
```typescript
|
|
253
|
+
```typescript fragment
|
|
254
254
|
// tests/flow/LoginPage.test.ts
|
|
255
255
|
import { describe, test, expect } from "bun:test";
|
|
256
256
|
import { FlowTest } from "@zerotal/flow/testing";
|
|
@@ -314,7 +314,7 @@ describe("LoginPage", () => {
|
|
|
314
314
|
|
|
315
315
|
To test that a component dispatches events and that `@on` listeners respond, mount each component separately and verify the dispatch effect:
|
|
316
316
|
|
|
317
|
-
```typescript
|
|
317
|
+
```typescript fragment
|
|
318
318
|
test("dispatches post-created when saved", async () => {
|
|
319
319
|
const editor = await FlowTest.mount(PostEditorPage);
|
|
320
320
|
await t.set("title", "My post");
|
|
@@ -340,7 +340,7 @@ test("post list responds to post-created event", async () => {
|
|
|
340
340
|
|
|
341
341
|
## Testing redirects and navigation
|
|
342
342
|
|
|
343
|
-
```typescript
|
|
343
|
+
```typescript fragment
|
|
344
344
|
test("redirects to the created post after save", async () => {
|
|
345
345
|
const t = await FlowTest.mount(NewPostPage);
|
|
346
346
|
await t.set("title", "Hello");
|
|
@@ -387,7 +387,7 @@ A page can pass every `FlowTest` assertion and still do nothing when a person cl
|
|
|
387
387
|
over the DevTools Protocol — no Puppeteer or Playwright dependency — so the click is a
|
|
388
388
|
real click and the round-trip is a real round-trip.
|
|
389
389
|
|
|
390
|
-
```ts
|
|
390
|
+
```ts fragment
|
|
391
391
|
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
|
392
392
|
import { Application, Router } from "zerotal";
|
|
393
393
|
import { FlowProvider } from "@zerotal/flow";
|
|
@@ -433,7 +433,7 @@ A few things are deliberate and worth copying:
|
|
|
433
433
|
- **Guard the suite with `FlowBrowser.available()`** so it skips where no browser is
|
|
434
434
|
installed instead of failing:
|
|
435
435
|
|
|
436
|
-
```ts
|
|
436
|
+
```ts fragment
|
|
437
437
|
const describeBrowser = FlowBrowser.available() ? describe : describe.skip;
|
|
438
438
|
```
|
|
439
439
|
|
package/docs/getting-started.md
CHANGED
|
@@ -12,7 +12,9 @@ provider.
|
|
|
12
12
|
## Requirements
|
|
13
13
|
|
|
14
14
|
- **Bun** ≥ 1.3.14 — [install](https://bun.sh/docs/installation)
|
|
15
|
-
- A
|
|
15
|
+
- A database, unless you scaffold the **Minimal** template. Every other template
|
|
16
|
+
is configured for **SQLite**, which needs nothing installed — PostgreSQL and
|
|
17
|
+
MySQL are supported and are a `DATABASE_URL` away.
|
|
16
18
|
|
|
17
19
|
## Create a new project
|
|
18
20
|
|
|
@@ -26,11 +28,11 @@ The scaffolder prompts for a project name and a template, then generates a
|
|
|
26
28
|
ready-to-run project and installs dependencies. The **database** prompt only
|
|
27
29
|
appears for the API template:
|
|
28
30
|
|
|
29
|
-
| Prompt | Options
|
|
30
|
-
| ------------ |
|
|
31
|
-
| Project name | Defaults to `my-zerotal-app`
|
|
32
|
-
| Template | **API**, **Flow**, **React**, **Vue**, or **Minimal** (see
|
|
33
|
-
| Database | **SQLite** (zero setup), **PostgreSQL**, or **MySQL** —
|
|
31
|
+
| Prompt | Options |
|
|
32
|
+
| ------------ | ---------------------------------------------------------------------------- |
|
|
33
|
+
| Project name | Defaults to `my-zerotal-app` |
|
|
34
|
+
| Template | **API**, **Admin**, **Flow**, **React**, **Vue**, or **Minimal** (see below) |
|
|
35
|
+
| Database | **SQLite** (zero setup), **PostgreSQL**, or **MySQL** — asked for **API** |
|
|
34
36
|
|
|
35
37
|
It writes a fresh `APP_KEY` into the generated `.env.example` for you (no manual
|
|
36
38
|
generation needed). For Postgres/MySQL it reminds you to set `DATABASE_URL`
|
|
@@ -47,7 +49,7 @@ bunx create-zerotal my-app --yes # take the defaults for anything uns
|
|
|
47
49
|
bunx create-zerotal --help
|
|
48
50
|
```
|
|
49
51
|
|
|
50
|
-
| Flag |
|
|
52
|
+
| Flag | Values / description |
|
|
51
53
|
| ------------------------- | -------------------------------------------------- |
|
|
52
54
|
| `-t`, `--template <name>` | `api`, `admin`, `flow`, `react`, `vue`, `minimal` |
|
|
53
55
|
| `--db <name>` | `sqlite`, `postgres`, `mysql` — API template only |
|
|
@@ -64,14 +66,17 @@ terminal: a half-built project that reports success is worse than one that stops
|
|
|
64
66
|
### Which template should I use?
|
|
65
67
|
|
|
66
68
|
- **API** — JSON REST API with core, [ORM](/docs/orm), [auth](/docs/authentication),
|
|
67
|
-
[validation](/docs/validator), and [testing](/docs/testing
|
|
69
|
+
[validation](/docs/validator), and [testing](/docs/testing). The default for a backend service.
|
|
70
|
+
- **Admin** — an [admin panel](/docs/admin): resources, sign-in, dashboard widgets, and
|
|
71
|
+
seeded demo data. `bun zt db:seed` gives you an account to log in with.
|
|
68
72
|
- **Flow** — server-driven reactive UI ([Flow](/docs/flow) pages, top nav, Tailwind).
|
|
69
73
|
- **React** — [Inertia](/docs/inertia) + React SPA with file-based routes and Tailwind.
|
|
70
74
|
- **Vue** — Inertia + Vue SPA with file-based routes and Tailwind.
|
|
71
75
|
- **Minimal** — a single page with JSX views and Tailwind on the bare framework.
|
|
72
76
|
|
|
73
|
-
> **Note** —
|
|
74
|
-
>
|
|
77
|
+
> **Note** — every template except **Minimal** ships a database config and migrations,
|
|
78
|
+
> configured for SQLite. Only the **API** template _asks_ which database you want; the
|
|
79
|
+
> rest start on SQLite and switch when you set `DATABASE_URL`.
|
|
75
80
|
|
|
76
81
|
## Project structure
|
|
77
82
|
|
|
@@ -162,7 +167,7 @@ generated `package.json` also gives you `bun start` (production serve) and
|
|
|
162
167
|
|
|
163
168
|
Open `routes/index.ts` and add:
|
|
164
169
|
|
|
165
|
-
```typescript
|
|
170
|
+
```typescript fragment
|
|
166
171
|
// routes/index.ts
|
|
167
172
|
import { Router } from "zerotal";
|
|
168
173
|
import { PostController } from "../app/controllers/PostController.ts";
|
|
@@ -239,7 +244,7 @@ bun zt migrate
|
|
|
239
244
|
|
|
240
245
|
Your model is ready to use:
|
|
241
246
|
|
|
242
|
-
```typescript
|
|
247
|
+
```typescript fragment
|
|
243
248
|
// in a controller or service
|
|
244
249
|
import { Post } from "../models/Post.ts";
|
|
245
250
|
|
|
@@ -257,7 +262,7 @@ Providers are where you wire up your own bindings. Scaffold one:
|
|
|
257
262
|
bun zt make:provider AppServiceProvider
|
|
258
263
|
```
|
|
259
264
|
|
|
260
|
-
```typescript
|
|
265
|
+
```typescript fragment
|
|
261
266
|
// app/providers/AppServiceProvider.ts
|
|
262
267
|
import { ServiceProvider } from "zerotal";
|
|
263
268
|
import { PaymentGateway } from "../services/PaymentGateway.ts";
|
package/docs/health.md
CHANGED
|
@@ -19,7 +19,7 @@ Health checks are built into `@zerotal/core` — nothing to install and no
|
|
|
19
19
|
provider to register. The endpoint is registered when the application starts:
|
|
20
20
|
|
|
21
21
|
```typescript
|
|
22
|
-
import { Health } from "zerotal";
|
|
22
|
+
import { Health } from "zerotal/health";
|
|
23
23
|
```
|
|
24
24
|
|
|
25
25
|
## Defaults
|
|
@@ -44,7 +44,11 @@ export default AppConfig({
|
|
|
44
44
|
health: {
|
|
45
45
|
enabled: true, // default: on outside production, off in production
|
|
46
46
|
path: "/health", // default: '/health'
|
|
47
|
-
|
|
47
|
+
// Spread rather than assigned, because `secret?: string` under
|
|
48
|
+
// `exactOptionalPropertyTypes` (which the templates set) will not take the
|
|
49
|
+
// `undefined` that `env()` returns when the variable is unset. The framework
|
|
50
|
+
// reads the field the same way.
|
|
51
|
+
...(env("HEALTH_KEY") ? { secret: env("HEALTH_KEY") } : {}), // required in production
|
|
48
52
|
showDetails: true, // false → bare { "status": "ok" }
|
|
49
53
|
},
|
|
50
54
|
});
|
|
@@ -98,7 +102,7 @@ a built-in `runtime` probe (memory, Bun version, in-flight requests). It becomes
|
|
|
98
102
|
a **readiness** probe as you register dependency checks against the `Health`
|
|
99
103
|
registry exported from `zerotal`:
|
|
100
104
|
|
|
101
|
-
```typescript
|
|
105
|
+
```typescript fragment
|
|
102
106
|
// in a bootstrap file (e.g. bootstrap/health.ts)
|
|
103
107
|
import { Health } from "zerotal/health";
|
|
104
108
|
|
|
@@ -113,7 +117,7 @@ Health.register(
|
|
|
113
117
|
|
|
114
118
|
// Non-critical failures degrade the report without failing readiness.
|
|
115
119
|
Health.register("cache", async () => {
|
|
116
|
-
await Cache.
|
|
120
|
+
await Cache.set("__health", "1", 5);
|
|
117
121
|
return { meta: { driver: "redis" } };
|
|
118
122
|
});
|
|
119
123
|
|
|
@@ -176,7 +180,7 @@ fails, since that is the case the endpoint exists for.
|
|
|
176
180
|
```typescript
|
|
177
181
|
// tests/health/checks.test.ts
|
|
178
182
|
import { test, expect, afterEach } from "bun:test";
|
|
179
|
-
import { Health } from "zerotal";
|
|
183
|
+
import { Health } from "zerotal/health";
|
|
180
184
|
|
|
181
185
|
afterEach(() => Health.clear());
|
|
182
186
|
|
|
@@ -189,7 +193,12 @@ test("a failing critical check brings the report down", async () => {
|
|
|
189
193
|
{ critical: true },
|
|
190
194
|
);
|
|
191
195
|
|
|
192
|
-
const report = await Health.run({
|
|
196
|
+
const report = await Health.run({
|
|
197
|
+
name: "app",
|
|
198
|
+
version: "1.0.0",
|
|
199
|
+
environment: "test",
|
|
200
|
+
uptime: 0, // required — seconds since boot, and a test has none worth reporting
|
|
201
|
+
});
|
|
193
202
|
|
|
194
203
|
expect(report.status).toBe("down");
|
|
195
204
|
expect(report.checks.database?.status).toBe("down");
|
|
@@ -205,7 +214,7 @@ to do with it.
|
|
|
205
214
|
design. A non-critical check that fails must leave the overall status up,
|
|
206
215
|
otherwise a flaky cache probe takes your deployment out of the load balancer:
|
|
207
216
|
|
|
208
|
-
```typescript
|
|
217
|
+
```typescript fragment
|
|
209
218
|
// tests/health/checks.test.ts
|
|
210
219
|
test("a non-critical failure degrades without going down", async () => {
|
|
211
220
|
Health.register("cache", async () => {
|
|
@@ -222,7 +231,7 @@ test("a non-critical failure degrades without going down", async () => {
|
|
|
222
231
|
**The endpoint's access rules deserve their own test**, because a health endpoint
|
|
223
232
|
that leaks internals is a reconnaissance gift:
|
|
224
233
|
|
|
225
|
-
```typescript
|
|
234
|
+
```typescript fragment
|
|
226
235
|
// tests/http/health.test.ts
|
|
227
236
|
const res = await app.get("/health");
|
|
228
237
|
|