@zerotal/arch 1.7.2 → 1.7.3
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/docs/about.md +4 -4
- package/docs/changelog.md +62 -6
- package/docs/contributing.md +2 -2
- package/docs/errors.md +22 -0
- package/docs/flow/components.md +4 -8
- package/docs/flow/decorators.md +17 -13
- package/docs/flow/events.md +3 -3
- package/docs/flow/index.md +11 -7
- package/docs/flow/layouts.md +1 -1
- package/docs/flow/lifecycle.md +31 -16
- package/docs/flow/models.md +284 -0
- package/docs/flow/performance.md +6 -6
- package/docs/flow/references.md +3 -3
- package/docs/flow/routing.md +14 -2
- package/docs/getting-started.md +9 -9
- package/docs/i18n.md +3 -3
- package/docs/routing.md +11 -0
- package/docs/support-policy.md +13 -5
- package/docs/upgrade.md +2 -0
- package/package.json +3 -3
package/docs/about.md
CHANGED
|
@@ -43,7 +43,7 @@ a theme.
|
|
|
43
43
|
|
|
44
44
|
### 1. Bun-native, source-only
|
|
45
45
|
|
|
46
|
-
Zerotal runs **only on Bun** (≥ 1.
|
|
46
|
+
Zerotal runs **only on Bun** (≥ 1.3.14) and leans on Bun's APIs throughout — `Bun.sql`
|
|
47
47
|
for the database, `Bun.CryptoHasher` for hashing, `Bun.build` for bundling. Because
|
|
48
48
|
Bun runs and type-strips TypeScript natively, packages ship as **`.ts` source with no
|
|
49
49
|
compiled `dist/`**. You always read real source and get accurate types; there's
|
|
@@ -660,9 +660,9 @@ The [README](../README.md) has a package-by-package table with links.
|
|
|
660
660
|
scaffolding, migrations, the dev server, the worker, and tests all run through it.
|
|
661
661
|
- **No build, ever.** `bun run dev` / `bun test` / `bun run typecheck`. No compile
|
|
662
662
|
step to remember.
|
|
663
|
-
- **
|
|
664
|
-
|
|
665
|
-
|
|
663
|
+
- **Starters are the fastest way in.** `bun create zerotal my-app` scaffolds a working
|
|
664
|
+
app from one of six starters — `api`, `admin`, `flow`, `react`, `vue`, or `minimal`.
|
|
665
|
+
Read the generated code alongside the docs.
|
|
666
666
|
- **Conventions are documented, not magic.** When something "just works" (a model you
|
|
667
667
|
never registered, a policy suddenly enforced), [Conventions](/docs/conventions)
|
|
668
668
|
explains exactly what the framework discovered and why.
|
package/docs/changelog.md
CHANGED
|
@@ -17,12 +17,68 @@ summary across the suite.
|
|
|
17
17
|
Each version lists changes under three headings:
|
|
18
18
|
|
|
19
19
|
- **Added** — new features and APIs (safe to adopt incrementally).
|
|
20
|
-
- **Changed** — behavior changes; **breaking** ones are called out explicitly
|
|
21
|
-
|
|
20
|
+
- **Changed** — behavior changes; **breaking** ones are called out explicitly, in
|
|
21
|
+
bold, as **BREAKING**.
|
|
22
22
|
- **Fixed** — bug fixes.
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
Breaking changes belong in major releases, and while the 1.x line is young they may
|
|
25
|
+
also land in a minor or a patch — always labelled, always with migration steps. Read
|
|
26
|
+
the section for every version you cross and apply its migration notes, not only the
|
|
27
|
+
majors. [Releases and versioning](/docs/support-policy#releases-and-versioning) explains
|
|
28
|
+
when that carve-out ends.
|
|
29
|
+
|
|
30
|
+
## 1.7.3 — 2026-08-20
|
|
31
|
+
|
|
32
|
+
Two fields that accepted input and threw it away, and a CI job that was testing nothing.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- **A boolean column could not hold a boolean on PostgreSQL.** `table.boolean()` compiled to
|
|
37
|
+
`INTEGER` on every engine — right for SQLite, which has no boolean type, and rejected by
|
|
38
|
+
PostgreSQL, which has a real one:
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
column "active" is of type integer but expression is of type boolean (42804)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Every insert of `true` failed, and so did every `where("active", true)`. The storage type
|
|
45
|
+
now comes from the dialect, as the auto-increment column already did. SQLite and MySQL are
|
|
46
|
+
unchanged — MySQL's `BOOLEAN` is a synonym for `TINYINT(1)` and `INTEGER` takes 0/1 either
|
|
47
|
+
way, so there was nothing broken there to fix.
|
|
48
|
+
|
|
49
|
+
**Existing PostgreSQL tables keep their integer columns.** New tables get `BOOLEAN`; a table
|
|
50
|
+
already created needs an `ALTER` if you want the column converted:
|
|
51
|
+
|
|
52
|
+
```sql
|
|
53
|
+
ALTER TABLE posts ALTER COLUMN active TYPE boolean USING active <> 0;
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
- **A bound password field discarded every keystroke.** Flow's client-writable set was
|
|
57
|
+
`fillable` minus `hidden`, which conflates two allow-lists answering different questions:
|
|
58
|
+
`fillable` governs what may be _written_, `hidden` governs what may be _shown_. A password
|
|
59
|
+
is in both, so subtracting made it unwritable — `<input type="password"
|
|
60
|
+
value={this.user.password} blur />` accepted typing and dropped it on arrival.
|
|
61
|
+
|
|
62
|
+
`hidden` is no longer subtracted. It is still never sent: the stored hash does not leave the
|
|
63
|
+
server and the field arrives empty. A hidden value **the client supplied** survives until
|
|
64
|
+
save; one **the server produced** is never echoed back, and a half-typed one is stripped
|
|
65
|
+
from the durable snapshot before it is persisted.
|
|
66
|
+
|
|
67
|
+
### Changed
|
|
68
|
+
|
|
69
|
+
- **The PostgreSQL CI job blocks merges.** It had been running the ORM suite beside a Postgres
|
|
70
|
+
container without connecting to it, so it reported success without testing anything. A smoke
|
|
71
|
+
suite now exercises schema DDL, identity columns, CRUD, type round-trips, row locks and
|
|
72
|
+
transaction rollback against a real PostgreSQL 16, and a failure fails the build. The
|
|
73
|
+
boolean defect above is what it found on its first real run.
|
|
74
|
+
|
|
75
|
+
### Documented
|
|
76
|
+
|
|
77
|
+
- **Flow pages take their model from the route, not from a query.** The docs opened every
|
|
78
|
+
model example by fetching the record in `onMount()`, which predates a route being able to
|
|
79
|
+
hand a component the record. `models.md` leads with the bound form; `lifecycle.md` no longer
|
|
80
|
+
presents the old id-plus-`onHydrate`-re-query as the correct pattern. The old shape still
|
|
81
|
+
works — it is simply two fields and a query doing what one field now does.
|
|
26
82
|
|
|
27
83
|
## 1.7.2 — 2026-08-18
|
|
28
84
|
|
|
@@ -50,7 +106,7 @@ saying so.
|
|
|
50
106
|
- **Flow bundles the socket client into its runtime.** A page that declares a `socket:`
|
|
51
107
|
listener is live with no script of your own. Flow apps own no bundle entry, so the contract
|
|
52
108
|
used to be "publish `window.Socket` yourself" — and when you didn't, the listeners were
|
|
53
|
-
|
|
109
|
+
_silently inert_: no error, no warning, no subscription, so a live feature with no script
|
|
54
110
|
looked exactly like a live feature that was never written. An app that needs a configured
|
|
55
111
|
client still assigns `window.Socket` before the runtime loads and that one is used as-is; a
|
|
56
112
|
page with no listeners opens no connection at all.
|
|
@@ -60,7 +116,7 @@ saying so.
|
|
|
60
116
|
- **A patch no longer writes back into a file input.** A file input's `value` belongs to the
|
|
61
117
|
user agent, and assigning anything but `""` throws `InvalidStateError`. The write was legal
|
|
62
118
|
while the bound property was empty and threw on the very patch carrying an upload's result
|
|
63
|
-
— and the throw escaped the frame handler, so the DOM never updated
|
|
119
|
+
— and the throw escaped the frame handler, so the DOM never updated _and_ the action's ack
|
|
64
120
|
never resolved. Since frames are chained per component, every later action queued behind a
|
|
65
121
|
promise that would never settle: the page rendered correctly and ignored every click for
|
|
66
122
|
the rest of its life.
|
package/docs/contributing.md
CHANGED
|
@@ -8,7 +8,7 @@ description: Get the Zerotal monorepo running locally and pass the checks your c
|
|
|
8
8
|
Zerotal is a Bun-native monorepo of composable packages. This guide covers getting the
|
|
9
9
|
repo running locally, the project layout, and the checks your change needs to pass.
|
|
10
10
|
|
|
11
|
-
> **Warning** — Bun ≥ 1.
|
|
11
|
+
> **Warning** — Bun ≥ 1.3.14 is required. Node.js is not supported; Zerotal uses `Bun.sql`, `Bun.CryptoHasher`, `Bun.build`, and other Bun-native APIs throughout.
|
|
12
12
|
|
|
13
13
|
## Getting set up
|
|
14
14
|
|
|
@@ -32,7 +32,7 @@ packages/ # the framework — one directory per @zerotal/* package
|
|
|
32
32
|
auth/ cache/ queue/ … # feature packages
|
|
33
33
|
testing/ # factories, fakes, test app harness
|
|
34
34
|
create-zerotal/ # the `bun create zerotal` scaffolder
|
|
35
|
-
apps/ #
|
|
35
|
+
apps/ # applications in this workspace
|
|
36
36
|
docs/ # this documentation site
|
|
37
37
|
docs/ # the markdown documentation (what you're reading)
|
|
38
38
|
```
|
package/docs/errors.md
CHANGED
|
@@ -77,6 +77,28 @@ The full set exported from `zerotal`:
|
|
|
77
77
|
> and `ServiceUnavailableError(reason, retryAfter)` populate the `Allow` and
|
|
78
78
|
> `Retry-After` response headers for you when you pass those arguments.
|
|
79
79
|
|
|
80
|
+
## Errors the framework raises
|
|
81
|
+
|
|
82
|
+
The errors above are the ones you throw. These the framework throws at you, and
|
|
83
|
+
each is written to name the fix rather than only the symptom. Most surface at
|
|
84
|
+
boot, before a request is ever served:
|
|
85
|
+
|
|
86
|
+
| Class | `code` | Raised when |
|
|
87
|
+
| ---------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
|
88
|
+
| `BootCheckError` | `E_BOOT_CHECK_FAILED` | The boot-time doctor found one or more wiring problems. |
|
|
89
|
+
| `ConfigValidationError` | `E_CONFIG_VALIDATION_FAILED` | Config validation refused a production boot, listing every fatal issue by namespace. |
|
|
90
|
+
| `FacadeBindingMissingError` | `E_FACADE_BEFORE_BOOT` | A facade was used after boot but nothing provides its binding — almost always a `ServiceProvider` missing from `bootstrap/providers.ts`. |
|
|
91
|
+
| `ContextOutsideRequestError` | `E_CONTEXT_OUTSIDE_REQUEST` | `RequestContext` was read with no request in scope, such as from module-level code or a background task. Renders as a 500. |
|
|
92
|
+
|
|
93
|
+
`BootCheckError` carries the whole list rather than the first failure it hit. Its
|
|
94
|
+
`failures` array holds one `BootCheckFailure` per culprit — the `token`, the
|
|
95
|
+
`provider` that declared it in `static provides`, and the `reason` — so a single
|
|
96
|
+
restart tells you everything there is to fix.
|
|
97
|
+
|
|
98
|
+
These four are not re-exported from `zerotal`, because throwing them yourself is
|
|
99
|
+
not the point. Catch one by class from `@zerotal/core/errors`, or match on `code`
|
|
100
|
+
the way you would any other framework error.
|
|
101
|
+
|
|
80
102
|
## Custom exception classes
|
|
81
103
|
|
|
82
104
|
Extend `ZerotalError` for domain errors, or `HttpError` for HTTP errors. The
|
package/docs/flow/components.md
CHANGED
|
@@ -489,25 +489,21 @@ A dropzone bound to an `@expose` property. Choosing a file POSTs the bytes to `/
|
|
|
489
489
|
import { Component, expose, FileUpload, FileUploads, TemporaryUploadedFile } from "@zerotal/flow";
|
|
490
490
|
|
|
491
491
|
export class AvatarPage extends Component.using(FileUploads) {
|
|
492
|
+
@locked user!: User; // /users/:user/avatar — the record, already found
|
|
492
493
|
@expose photo: TemporaryUploadedFile | null = null;
|
|
493
|
-
@locked photoUrl: string = "";
|
|
494
|
-
|
|
495
|
-
override async onMount() {
|
|
496
|
-
const user = await User.find(this.userId);
|
|
497
|
-
this.photoUrl = user?.avatarUrl ?? "";
|
|
498
|
-
}
|
|
499
494
|
|
|
500
495
|
@expose async save(): Promise<void> {
|
|
501
496
|
if (!this.photo) return;
|
|
502
497
|
const path = await this.photo.store("avatars"); // moves to permanent storage
|
|
503
|
-
|
|
498
|
+
this.user.avatarUrl = path;
|
|
499
|
+
await this.user.save();
|
|
504
500
|
this.flash("Avatar updated.", "success");
|
|
505
501
|
}
|
|
506
502
|
|
|
507
503
|
override async render() {
|
|
508
504
|
return (
|
|
509
505
|
<div class="space-y-4">
|
|
510
|
-
{this.
|
|
506
|
+
{this.user.avatarUrl && <img src={this.user.avatarUrl} class="h-24 w-24 rounded-full" />}
|
|
511
507
|
|
|
512
508
|
<FileUpload bind={this.photo} accept="image/*" maxSize="5mb" />
|
|
513
509
|
|
package/docs/flow/decorators.md
CHANGED
|
@@ -42,18 +42,19 @@ Only `@expose` properties are two-way: the client can push updates back to the s
|
|
|
42
42
|
|
|
43
43
|
Sent to the client for display, but the client cannot mutate it. Included in the snapshot so it survives WebSocket round-trips without re-loading from the database.
|
|
44
44
|
|
|
45
|
+
An ORM model is the exception: only its id travels in the snapshot and the row is re-read on every round-trip, so what the page shows is always current. See [Models in Components](/docs/flow/models).
|
|
46
|
+
|
|
45
47
|
A `value={this.x}` binding on a `@locked` property renders as a read-only display — not an editable field.
|
|
46
48
|
|
|
47
|
-
Use `@locked` for
|
|
49
|
+
Use `@locked` for anything the server owns: a record the route resolved, results loaded in `onMount()`, computed totals, child props from the parent:
|
|
48
50
|
|
|
49
51
|
```tsx
|
|
50
52
|
export class PostsPage extends Component {
|
|
53
|
+
@locked user!: User; // /users/:user — the record, already found
|
|
51
54
|
@locked posts: Post[] = [];
|
|
52
|
-
@locked user: User | null = null;
|
|
53
55
|
@locked total: number = 0;
|
|
54
56
|
|
|
55
57
|
override async onMount() {
|
|
56
|
-
this.user = await User.findOrFail(this.userId);
|
|
57
58
|
this.posts = await Post.query()
|
|
58
59
|
.where("user_id", this.user.id)
|
|
59
60
|
.where("status", "published")
|
|
@@ -65,7 +66,7 @@ export class PostsPage extends Component {
|
|
|
65
66
|
override async render() {
|
|
66
67
|
return (
|
|
67
68
|
<div>
|
|
68
|
-
<h1>Posts by {this.user
|
|
69
|
+
<h1>Posts by {this.user.name}</h1>
|
|
69
70
|
<p>{this.total} posts</p>
|
|
70
71
|
<ul>
|
|
71
72
|
{this.posts.map((p) => (
|
|
@@ -269,7 +270,8 @@ An `@expose`d method that runs on the server but **skips the re-render cycle**.
|
|
|
269
270
|
}
|
|
270
271
|
|
|
271
272
|
@expose @renderless async archivePost(): Promise<void> {
|
|
272
|
-
|
|
273
|
+
this.post.status = "archived"; // this.post came from the route
|
|
274
|
+
await this.post.save();
|
|
273
275
|
this.redirect("/posts");
|
|
274
276
|
}
|
|
275
277
|
```
|
|
@@ -307,6 +309,8 @@ async onOrderCancelled(payload: { id: number }): Promise<void> {
|
|
|
307
309
|
}
|
|
308
310
|
```
|
|
309
311
|
|
|
312
|
+
Both forms are a `ListenerName`: the event string itself, or a resolver handed the component instance that returns one.
|
|
313
|
+
|
|
310
314
|
See [Events & Broadcasting](/docs/flow/events) for dispatch methods, targeting, broadcasting, and native event integration.
|
|
311
315
|
|
|
312
316
|
## The @reactive decorator
|
|
@@ -396,8 +400,8 @@ See [Layouts & Composition](/docs/flow/layouts#two-way-props) for the full `@mod
|
|
|
396
400
|
| `@expose method` | Callable from the browser via WebSocket |
|
|
397
401
|
| `@expose @renderless method` | Callable from browser; skips re-render cycle |
|
|
398
402
|
| `@on("event") method` | Listens for cross-component events (auto-exposed) |
|
|
399
|
-
| `@on("socket:channel,Event") method`
|
|
400
|
-
| `@on((self) => "socket:…") method`
|
|
403
|
+
| `@on("socket:channel,Event") method` | Listens for real-time server broadcasts |
|
|
404
|
+
| `@on((self) => "socket:…") method` | Same, with the channel resolved per instance (record ids) |
|
|
401
405
|
| `@reactive prop` | Child prop; parent re-pushes on change, child re-renders |
|
|
402
406
|
| `@modelable prop` | Two-way child prop; writes from child flow back to parent |
|
|
403
407
|
|
|
@@ -421,13 +425,13 @@ this.redirectIntended("/dashboard"); // back to where AuthMiddleware intercepted
|
|
|
421
425
|
// Force onMount() to re-run this round-trip (useful for reloading stale data)
|
|
422
426
|
this.refresh();
|
|
423
427
|
|
|
424
|
-
//
|
|
425
|
-
this.title("Edit post — My App");
|
|
428
|
+
// The document title is `static title` on the class, not an action — see Routing.
|
|
426
429
|
|
|
427
|
-
// Run
|
|
428
|
-
|
|
429
|
-
this.
|
|
430
|
-
this.
|
|
430
|
+
// Run JavaScript in the browser after the DOM patch is applied. `$` is a tagged
|
|
431
|
+
// template, so interpolated values are encoded for you.
|
|
432
|
+
this.$`$refs.titleInput.focus()`;
|
|
433
|
+
this.$`window.scrollTo({ top: 0, behavior: 'smooth' })`;
|
|
434
|
+
this.$`$dispatch('toast', { message: ${this.message} })`;
|
|
431
435
|
|
|
432
436
|
// Trigger a file download in the browser
|
|
433
437
|
this.download("report.csv", csvContent, "text/csv;charset=utf-8");
|
package/docs/flow/events.md
CHANGED
|
@@ -276,8 +276,8 @@ export class OrderDashboard extends Component {
|
|
|
276
276
|
|
|
277
277
|
### Channel name formats
|
|
278
278
|
|
|
279
|
-
| Format
|
|
280
|
-
|
|
|
279
|
+
| Format | Channel type |
|
|
280
|
+
| ------------------------------------ | --------------------------------------- |
|
|
281
281
|
| `socket:channel,Event` | Public channel |
|
|
282
282
|
| `socket-private:channel,Event` | Private channel (requires auth) |
|
|
283
283
|
| `socket-presence:room,joining` | Presence channel — member joined |
|
|
@@ -315,7 +315,7 @@ The resolver runs once per render, after `onMount()`, so it can read anything th
|
|
|
315
315
|
loaded. If it throws — a field it reads is still null, say — that one listener is dropped and the
|
|
316
316
|
page renders without it, rather than the render failing.
|
|
317
317
|
|
|
318
|
-
Resolve the
|
|
318
|
+
Resolve the _narrowest_ channel the reader is entitled to. A static `issues` channel with an
|
|
319
319
|
`if (payload.issueId !== this.issue.id) return` in the handler looks equivalent and is not: the
|
|
320
320
|
broadcast still reaches every subscriber's browser, so every reader receives every issue's
|
|
321
321
|
comment bodies and discards them after the fact.
|
package/docs/flow/index.md
CHANGED
|
@@ -139,7 +139,7 @@ component in the project is covered — the Flow scaffold already writes this:
|
|
|
139
139
|
> is a type error rather than a wrong render. The two JSX runtimes produce different element
|
|
140
140
|
> types: a view `FC` returns `SafeHtml` (`{ value }`) and Flow's JSX expects `HtmlNode`
|
|
141
141
|
> (`{ html }`), so using one inside the other is `TS2786: 'Box' cannot be used as a JSX
|
|
142
|
-
|
|
142
|
+
component`. A shared component library has to target one runtime; share class-name constants
|
|
143
143
|
> or plain strings across the two instead of components.
|
|
144
144
|
|
|
145
145
|
### Your first component
|
|
@@ -183,22 +183,26 @@ Router.flow("/counter", CounterPage);
|
|
|
183
183
|
### Reserved member names
|
|
184
184
|
|
|
185
185
|
`Component` brings its own members, and a property of yours that collides with one is a
|
|
186
|
-
type error. It is caught at compile time and the message is specific,
|
|
187
|
-
|
|
188
|
-
|
|
186
|
+
type error. It is caught at compile time and the message is specific, so the cost is the
|
|
187
|
+
surprise rather than the failure — which is why the list is here.
|
|
188
|
+
|
|
189
|
+
`title` used to be on it, and was the name that caught people most: an obvious field for a
|
|
190
|
+
row representing a media item, a guide or a review. The document title is
|
|
191
|
+
[`static title`](#the-page-title) now, so the instance name is yours.
|
|
189
192
|
|
|
190
193
|
The names in use:
|
|
191
194
|
|
|
192
195
|
| Group | Names |
|
|
193
196
|
| ----------------- | --------------------------------------------------------------------------------------------------------------------- |
|
|
194
197
|
| Lifecycle | `onBoot` `onMount` `onHydrate` `onDehydrate` `onRendering` `onRendered` `onUpdate` `onUpdating` `onUpdated` `onError` |
|
|
195
|
-
| Rendering | `render` `layout` `placeholder` `slot` `hasSlot` `child`
|
|
198
|
+
| Rendering | `render` `layout` `placeholder` `slot` `hasSlot` `child` |
|
|
196
199
|
| Actions & state | `bind` `validate` `resetValidation` `errors` `addError` `refresh` `$refresh` `$set` `cancelled` `signal` |
|
|
197
200
|
| Navigation | `redirect` `redirectRoute` `redirectIntended` `currentUrl` `navigateCurrent` |
|
|
198
|
-
| Events & realtime | `dispatch` `dispatchSelf` `dispatchTo` `stream` `client`
|
|
201
|
+
| Events & realtime | `dispatch` `dispatchSelf` `dispatchTo` `stream` `client` `$` |
|
|
199
202
|
| Misc | `flash` `download` `clearDurable` |
|
|
200
203
|
|
|
201
|
-
Anything beginning with `_` is also framework-internal, as
|
|
204
|
+
Anything beginning with `_` is also framework-internal, as are the statics `durable`
|
|
205
|
+
and `title`.
|
|
202
206
|
|
|
203
207
|
If the natural name is taken, the usual fix is a more specific one — `headline`,
|
|
204
208
|
`mediaTitle` — which often reads better than `title` did.
|
package/docs/flow/layouts.md
CHANGED
|
@@ -149,7 +149,7 @@ Flow assembles the outer document itself and emits `<html lang="en">`, and a `La
|
|
|
149
149
|
change it. `head` injects into `<head>`; the `<html>` attributes are not reachable from a layout.
|
|
150
150
|
|
|
151
151
|
For a localised app, put `lang` (and any locale-dependent class) on the layout's own wrapper element.
|
|
152
|
-
Both are valid on a `div` and apply to every descendant, so the
|
|
152
|
+
Both are valid on a `div` and apply to every descendant, so the _content_ is correctly marked up —
|
|
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
|
|
package/docs/flow/lifecycle.md
CHANGED
|
@@ -94,11 +94,19 @@ Avoid expensive database queries in `onBoot()` — it fires on every round-trip,
|
|
|
94
94
|
|
|
95
95
|
Runs once on the initial `GET` render, then is skipped on all subsequent WebSocket updates. It's the primary place to load data for the page.
|
|
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
99
|
```typescript
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
export class PostPage extends Component {
|
|
101
|
+
@locked post!: Post; // /posts/:post — nothing to load
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
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
|
+
|
|
107
|
+
```typescript
|
|
108
|
+
override async onMount({ user }: HttpContext) {
|
|
109
|
+
this.canEdit = user?.id === this.post.authorId;
|
|
102
110
|
}
|
|
103
111
|
```
|
|
104
112
|
|
|
@@ -106,21 +114,23 @@ The context is passed to `onBoot()` too, but only the initial `GET` populates `c
|
|
|
106
114
|
|
|
107
115
|
```typescript
|
|
108
116
|
override async onMount() {
|
|
109
|
-
const [posts,
|
|
117
|
+
const [posts, drafts] = await Promise.all([
|
|
110
118
|
Post.query()
|
|
111
119
|
.where("status", "published")
|
|
112
120
|
.orderBy("created_at", "desc")
|
|
113
121
|
.limit(20)
|
|
114
122
|
.get(),
|
|
115
|
-
|
|
123
|
+
Post.query().where("status", "draft").count(),
|
|
116
124
|
]);
|
|
117
125
|
|
|
118
|
-
this.posts
|
|
119
|
-
this.
|
|
120
|
-
this.total
|
|
126
|
+
this.posts = posts;
|
|
127
|
+
this.drafts = drafts;
|
|
128
|
+
this.total = posts.length;
|
|
121
129
|
}
|
|
122
130
|
```
|
|
123
131
|
|
|
132
|
+
Lists and counts are what belongs here — the things no route resolved and no parent passed down.
|
|
133
|
+
|
|
124
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:
|
|
125
135
|
|
|
126
136
|
```typescript
|
|
@@ -139,25 +149,30 @@ Runs on every WebSocket round-trip, immediately after state is restored from the
|
|
|
139
149
|
|
|
140
150
|
```typescript
|
|
141
151
|
export class PostEditorPage extends Component {
|
|
142
|
-
@
|
|
143
|
-
@transient
|
|
152
|
+
@expose post!: Post; // /posts/:post/edit — re-read from the row every round-trip
|
|
153
|
+
@transient wordCount = 0; // NOT persisted — derived again each time
|
|
144
154
|
|
|
145
155
|
override async onHydrate() {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
this.post = await Post.findOrFail(this.postId);
|
|
149
|
-
}
|
|
156
|
+
await this.post.loadMissing(["author"]); // relations do not survive the round-trip
|
|
157
|
+
this.wordCount = this.post.body.split(/\s+/).length;
|
|
150
158
|
}
|
|
151
159
|
|
|
152
160
|
@expose async updateTitle(title: string): Promise<void> {
|
|
153
|
-
if (!this.post) return;
|
|
154
161
|
await this.post.fill({ title }).save();
|
|
155
162
|
this.flash("Title updated.");
|
|
156
163
|
}
|
|
157
164
|
}
|
|
158
165
|
```
|
|
159
166
|
|
|
160
|
-
This
|
|
167
|
+
> **This used to say to hold the id and re-query the model here.** A component could not hold a
|
|
168
|
+
> model then, so `@locked postId` plus a `@transient post` re-fetched in `onHydrate()` was the
|
|
169
|
+
> way to keep one fresh. It is no longer needed: a model held directly is [re-read from its row
|
|
170
|
+
> on every round-trip](/docs/flow/models#freshness), which is the same guarantee with none of
|
|
171
|
+
> the bookkeeping. The pattern still works — it is just two fields and a query doing what one
|
|
172
|
+
> field now does.
|
|
173
|
+
|
|
174
|
+
What is left for `onHydrate()` is the state a snapshot genuinely cannot carry: relations, which
|
|
175
|
+
are not part of a re-read, and anything derived from them.
|
|
161
176
|
|
|
162
177
|
## Intercepting client writes
|
|
163
178
|
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Models in Components
|
|
3
|
+
description: Put an ORM model on a Flow component — what crosses the wire, what the client may change, and what stays on the server.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Models in Components
|
|
7
|
+
|
|
8
|
+
A Flow component can hold an ORM model directly. On a route that names one, it arrives loaded:
|
|
9
|
+
|
|
10
|
+
```tsx
|
|
11
|
+
import { Component, locked } from "@zerotal/flow";
|
|
12
|
+
import { Post } from "@app/models/Post.ts";
|
|
13
|
+
|
|
14
|
+
export class ShowPost extends Component {
|
|
15
|
+
@locked post!: Post; // /posts/:post — the record, already found
|
|
16
|
+
|
|
17
|
+
override async render() {
|
|
18
|
+
return <article>{this.post.title}</article>;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
There is no `onMount()`, because there is nothing to fetch. The router resolved `:post` before
|
|
24
|
+
the component was built, and a field of that type receives the result — so the query, the
|
|
25
|
+
`postId` field it would have needed, and the 404 handling all belong to the route rather than
|
|
26
|
+
to this page. [Path parameters](/docs/flow/routing#path-parameters) covers what a segment
|
|
27
|
+
binds to and how a model resolves by something other than its primary key.
|
|
28
|
+
|
|
29
|
+
The other way a model arrives is from a parent that already has it, as a prop:
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
<PostCard post={this.post} />
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Either way the model is a model on the other side — not a plain object shaped like one — and
|
|
36
|
+
everything below applies the same to both.
|
|
37
|
+
|
|
38
|
+
Nothing has to be declared. A model travels under its table name — the one `@table("…")`
|
|
39
|
+
sets, or the inflected default — which the app already declares and a minifier cannot mangle.
|
|
40
|
+
On the way back it is found through the registry that `app/models` discovery populates, which
|
|
41
|
+
is every model in the app.
|
|
42
|
+
|
|
43
|
+
> **Fetching a model in `onMount()` is the older way of doing this**, from before a component
|
|
44
|
+
> could hold one. It still runs, and it is still right for a record no route names and no
|
|
45
|
+
> parent has — a list, a lookup keyed off something the URL does not carry. It is the wrong
|
|
46
|
+
> shape for the record the page is _about_: that one the route already found.
|
|
47
|
+
|
|
48
|
+
The decorator is the whole decision: **`@locked` for a model the page displays, `@expose` for
|
|
49
|
+
one it edits.** Both put the model on the client; only `@expose` accepts anything back.
|
|
50
|
+
|
|
51
|
+
## What crosses the wire
|
|
52
|
+
|
|
53
|
+
The snapshot carries the model's **id** and the result of its `toJSON()` — the same
|
|
54
|
+
serialisation your API responses use, honouring `visible`, `hidden` and `appends`.
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
@table("users")
|
|
58
|
+
export class User extends BaseModel {
|
|
59
|
+
static fillable = ["name", "email", "password"];
|
|
60
|
+
static hidden = ["password"];
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`password` is never sent. Nothing else has to be configured for that: the model already
|
|
65
|
+
declares its serialisation surface, and Flow uses it.
|
|
66
|
+
|
|
67
|
+
> **`hidden` is a security control here.** On an API response an omitted column is a matter of
|
|
68
|
+
> shape. On a component it is the only thing keeping a value out of the page. A snapshot is
|
|
69
|
+
> signed, not encrypted — the browser can read every byte of it. Declare `hidden` (or
|
|
70
|
+
> `visible`) on any model a component holds.
|
|
71
|
+
|
|
72
|
+
A loaded relation is resolved the same way, through its own `toJSON()` — every model hides
|
|
73
|
+
its own columns, however deep it sits.
|
|
74
|
+
|
|
75
|
+
The id travels separately from the values, in the part of the snapshot a client write cannot
|
|
76
|
+
reach. **Which record a component points at is not something the browser can change** — only
|
|
77
|
+
the values on it, and only the ones below.
|
|
78
|
+
|
|
79
|
+
## What the client may change
|
|
80
|
+
|
|
81
|
+
Only `fillable`. Everything else is server-owned, whatever the browser sends:
|
|
82
|
+
|
|
83
|
+
| Column | Sent to the client | Client may write |
|
|
84
|
+
| ------------------------------------ | ------------------ | ---------------- |
|
|
85
|
+
| `id` — the identity | yes | no |
|
|
86
|
+
| `name` — fillable, not hidden | yes | **yes** |
|
|
87
|
+
| `role` — not fillable | yes | no |
|
|
88
|
+
| `password` — fillable **and** hidden | no | **yes** |
|
|
89
|
+
|
|
90
|
+
A model that declares no `fillable` is read-only in the browser. That is the ORM's default:
|
|
91
|
+
it guards mass assignment until told otherwise, and a component does not widen it.
|
|
92
|
+
|
|
93
|
+
A field outside that set is **ignored**, not rejected — a crafted payload does not become a
|
|
94
|
+
server error. To refuse a value loudly instead, or to vet one before it lands, throw from
|
|
95
|
+
[`onUpdating()`](/docs/flow/lifecycle#intercepting-client-writes).
|
|
96
|
+
|
|
97
|
+
### Editing
|
|
98
|
+
|
|
99
|
+
Bind to a field of an `@expose`d model and it is two-way:
|
|
100
|
+
|
|
101
|
+
```tsx
|
|
102
|
+
export class EditProfile extends Component {
|
|
103
|
+
@expose user!: User;
|
|
104
|
+
|
|
105
|
+
@expose async save(): Promise<void> {
|
|
106
|
+
if (this.user.name.trim().length < 2) {
|
|
107
|
+
this.addError("name", "Your name needs at least two characters.");
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
await this.user.save();
|
|
111
|
+
this.flash("Saved");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
override async render() {
|
|
115
|
+
return (
|
|
116
|
+
<form onSubmit={this.save}>
|
|
117
|
+
<input value={this.user.name} blur />
|
|
118
|
+
<span error={this.errors.name} />
|
|
119
|
+
<button>Save</button>
|
|
120
|
+
</form>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`save()` writes only the columns that actually differ. The row was re-read a moment earlier
|
|
127
|
+
(see [Freshness](#freshness)), so the model's idea of "unchanged" is the row as it is now, not
|
|
128
|
+
the one the page was built from.
|
|
129
|
+
|
|
130
|
+
A `@locked` model is display-only, and its fields are not bound: render them as text —
|
|
131
|
+
`<p>{this.post.title}</p>` — rather than as an input. An input pointed at one accepts typing
|
|
132
|
+
and sends nothing.
|
|
133
|
+
|
|
134
|
+
### Validating a model's fields
|
|
135
|
+
|
|
136
|
+
`this.validate()` rules are keyed by the component's **own** exposed properties, and a model's
|
|
137
|
+
columns are not among them: `"user.name"` looks for a property with that name, finds nothing,
|
|
138
|
+
and fails whatever the field actually holds. Two ways round it:
|
|
139
|
+
|
|
140
|
+
- **Check in the action**, then `this.addError(field, message)` — as above. Right for a field
|
|
141
|
+
or two.
|
|
142
|
+
- **Use a [form object](/docs/flow/forms)** for anything larger. It is the shape built for
|
|
143
|
+
validated multi-field editing, and it can carry values no column has: a confirmation field,
|
|
144
|
+
a current-password check, an upload.
|
|
145
|
+
|
|
146
|
+
### Hidden fields are writable
|
|
147
|
+
|
|
148
|
+
`hidden` governs what is _shown_, not what may be written. A password is the case that makes
|
|
149
|
+
the difference: fillable because a user sets it, hidden because the stored hash must never
|
|
150
|
+
reach the page.
|
|
151
|
+
|
|
152
|
+
```tsx
|
|
153
|
+
<input type="password" value={this.user.password} blur />
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The stored hash is never sent, so the field starts empty. What the user types is held until
|
|
157
|
+
they save — it travels back to the browser that produced it, and nowhere else. A value the
|
|
158
|
+
_server_ set is never echoed, and a half-typed one is never written to the
|
|
159
|
+
[durable store](/docs/flow/lifecycle).
|
|
160
|
+
|
|
161
|
+
What reaches the database is a hash rather than what was typed, as long as the column is
|
|
162
|
+
listed in the model's `hashable` — see [Password hashing](/docs/orm#password-hashing).
|
|
163
|
+
|
|
164
|
+
## Relations
|
|
165
|
+
|
|
166
|
+
A relation that is loaded when the page renders travels with the model, through its own
|
|
167
|
+
`toJSON()`. It does not survive the round-trip:
|
|
168
|
+
|
|
169
|
+
```tsx
|
|
170
|
+
override async onMount(): Promise<void> {
|
|
171
|
+
await this.post.loadMissing(["author"]); // this.post came from the route
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
That renders. **The next interaction has no `author`** — the re-read is a find by id, which
|
|
176
|
+
fetches the row and not the relations that happened to be loaded around it. Reading it then
|
|
177
|
+
throws the ORM's guard: `Relation "author" was accessed on Post without eager loading`.
|
|
178
|
+
|
|
179
|
+
Load what an action needs, where it needs it:
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
@expose async approve(): Promise<void> {
|
|
183
|
+
await this.post.loadMissing(["author"]);
|
|
184
|
+
this.post.approved = true;
|
|
185
|
+
await this.post.save();
|
|
186
|
+
this.flash(`Approved — ${this.post.author.name} has been credited.`);
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
When the page _displays_ the relation, load it once per round-trip in
|
|
191
|
+
[`onHydrate()`](/docs/flow/lifecycle#re-deriving-state-after-hydration) instead. Rendering is
|
|
192
|
+
the case worth watching: a template reading `this.post.author.name` works on the first paint
|
|
193
|
+
and throws on every interaction after it.
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
override async onHydrate(): Promise<void> {
|
|
197
|
+
await this.post.loadMissing(["author"]);
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## Freshness
|
|
202
|
+
|
|
203
|
+
The model is **re-read from the database on every round-trip**, by id. What the client holds
|
|
204
|
+
is a rendering of a row, not the row.
|
|
205
|
+
|
|
206
|
+
Two consequences worth knowing:
|
|
207
|
+
|
|
208
|
+
- **Server-owned columns are always current.** If someone else changes `role`, the next
|
|
209
|
+
interaction shows the new value.
|
|
210
|
+
- **Unsaved changes survive.** A change an action made without calling `save()` is restored
|
|
211
|
+
from the snapshot, so a half-filled field does not revert the moment something else happens.
|
|
212
|
+
Only the writable fields are restored; everything else comes from the fresh row.
|
|
213
|
+
|
|
214
|
+
If two people edit the same record, the one who saves last wins. Flow does not add optimistic
|
|
215
|
+
locking — add a version column and check it in `save()` if a record needs it.
|
|
216
|
+
|
|
217
|
+
### A row that disappears
|
|
218
|
+
|
|
219
|
+
The re-read is a `findOrFail`, and it applies the same scopes your own queries do. A record
|
|
220
|
+
deleted — or soft-deleted — while a page holds it makes the next interaction fail: nothing is
|
|
221
|
+
patched, and the browser console carries the error. So follow a delete with a navigation
|
|
222
|
+
rather than leaving the prop pointing at a row that is gone:
|
|
223
|
+
|
|
224
|
+
```ts
|
|
225
|
+
@expose async destroy(): Promise<void> {
|
|
226
|
+
await this.post.delete();
|
|
227
|
+
this.redirect("/posts");
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## Collections
|
|
232
|
+
|
|
233
|
+
An array of models is sent as ids and re-read with a single `whereIn` query:
|
|
234
|
+
|
|
235
|
+
```tsx
|
|
236
|
+
@locked posts: Post[] = [];
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Collections are read-only on the client. Bind to a single model when you need to edit one.
|
|
240
|
+
|
|
241
|
+
Two things follow from ids and one `whereIn`:
|
|
242
|
+
|
|
243
|
+
- **Order does not travel.** The re-read carries no `order by`, so the order you loaded them
|
|
244
|
+
in is not guaranteed to come back. Sort where you render, or re-run the real query in
|
|
245
|
+
`onHydrate()`.
|
|
246
|
+
- **Rows that vanish drop out quietly.** A `whereIn` returns what it finds, so a deleted
|
|
247
|
+
record leaves the array one shorter — unlike a single model, whose disappearance fails the
|
|
248
|
+
interaction.
|
|
249
|
+
|
|
250
|
+
## What a round-trip costs
|
|
251
|
+
|
|
252
|
+
Each model prop is one query per interaction, and each collection one more. That is the price
|
|
253
|
+
of never showing a stale row, and for most pages it is the right trade. When it is not:
|
|
254
|
+
|
|
255
|
+
- **[`@transient`](/docs/flow/decorators#the-transient-decorator)** keeps a value off the
|
|
256
|
+
client and out of the snapshot entirely — nothing is sent, and nothing is re-read.
|
|
257
|
+
- **Hold an id and query it yourself** in `onHydrate()` when the query needs shaping the
|
|
258
|
+
re-read cannot do: eager loads, ordering, a scope. `@locked postId: number` plus one query
|
|
259
|
+
is the whole pattern.
|
|
260
|
+
- **A [form object](/docs/flow/forms)** when the page is editing many fields — it edits a
|
|
261
|
+
plain object, and touches the database once, in your action.
|
|
262
|
+
|
|
263
|
+
## Troubleshooting
|
|
264
|
+
|
|
265
|
+
| What you see | What it means |
|
|
266
|
+
| --------------------------------------- | -------------------------------------------------------------------- |
|
|
267
|
+
| `No model maps to "posts"` | The class is not in `app/models`, or its `@table` no longer matches. |
|
|
268
|
+
| `Post has no table name` | Give the class `@table("…")`. |
|
|
269
|
+
| `Relation "author" was accessed` | It was loaded in `onMount()`, and this is a later round-trip. |
|
|
270
|
+
| An edited field never saves | It is not in `fillable`, so the write was ignored. |
|
|
271
|
+
| A field snaps back after an interaction | The same cause, seen from the page: the fresh row won the re-render. |
|
|
272
|
+
| A password field empties itself | The column is `hidden` but not `fillable`, so nothing was applied. |
|
|
273
|
+
|
|
274
|
+
## Next steps
|
|
275
|
+
|
|
276
|
+
- [Decorators](/docs/flow/decorators) — `@expose`, `@locked` and the rest of the property
|
|
277
|
+
contract.
|
|
278
|
+
- [Lifecycle](/docs/flow/lifecycle) — `onHydrate()`, `onUpdating()` and where per-round-trip
|
|
279
|
+
work belongs.
|
|
280
|
+
- [Forms & Uploads](/docs/flow/forms) — form objects, which are the right shape for
|
|
281
|
+
multi-field editing and for anything a model cannot carry.
|
|
282
|
+
- [Mass assignment](/docs/orm#mass-assignment) — `fillable`, `guarded`, `hidden` and
|
|
283
|
+
`visible`, which this page builds on.
|
|
284
|
+
- [Relationships](/docs/orm/relationships) — eager loading, `load()` and `loadMissing()`.
|
package/docs/flow/performance.md
CHANGED
|
@@ -49,12 +49,12 @@ N of M Flow pages (75%) render through the runtime fallback instead of compiled
|
|
|
49
49
|
|
|
50
50
|
Set `ZT_FLOW_COMPILE_LOG=1` to print what blocks each page. The common causes:
|
|
51
51
|
|
|
52
|
-
| Blocker
|
|
53
|
-
|
|
|
54
|
-
| `render()` with more than one `return`
|
|
55
|
-
| A function call in a text child — including **`__()`**
|
|
56
|
-
| An imported child component in `render()` (`<Header/>`)
|
|
57
|
-
| `class={someLocalConst}` or a numeric-literal attribute (`rows={3}`) | Use a literal string
|
|
52
|
+
| Blocker | Fix |
|
|
53
|
+
| -------------------------------------------------------------------- | -------------------------------------------------- |
|
|
54
|
+
| `render()` with more than one `return` | Build the branches into a variable and return once |
|
|
55
|
+
| A function call in a text child — including **`__()`** | See below |
|
|
56
|
+
| An imported child component in `render()` (`<Header/>`) | Inline it, or accept the fallback |
|
|
57
|
+
| `class={someLocalConst}` or a numeric-literal attribute (`rows={3}`) | Use a literal string |
|
|
58
58
|
|
|
59
59
|
**`__()` is the one that matters most.** A translated template is a function call in a text child,
|
|
60
60
|
so a page that translates a single string falls off the fast path — which in an app where `__()`
|
package/docs/flow/references.md
CHANGED
|
@@ -180,7 +180,7 @@ override async render() {
|
|
|
180
180
|
|
|
181
181
|
> **The payload does not say which container took the drop.** The client reads `flow:sort` off the
|
|
182
182
|
> container a child was dropped **into** and calls it `(key, index)` — so the destination is
|
|
183
|
-
> encoded in
|
|
183
|
+
> encoded in _which method runs_, and nowhere else. For a single sortable list that is invisible.
|
|
184
184
|
> For dragging **between** containers under one `sortGroup` it means one action per container:
|
|
185
185
|
>
|
|
186
186
|
> ```tsx
|
|
@@ -189,7 +189,7 @@ override async render() {
|
|
|
189
189
|
> ```
|
|
190
190
|
>
|
|
191
191
|
> An arrow (`onSort={(k, i) => this.move("todo", k, i)}`) cannot stand in, because the attribute's
|
|
192
|
-
> value is used as a method
|
|
192
|
+
> value is used as a method _name_ rather than evaluated. `onSort` accepts the name as a string,
|
|
193
193
|
> so the handlers can come from a lookup table keyed by column, but they must be declared members.
|
|
194
194
|
|
|
195
195
|
### DOM utilities
|
|
@@ -197,7 +197,7 @@ override async render() {
|
|
|
197
197
|
| You write | Behaviour | Compiles to |
|
|
198
198
|
| ----------------- | ------------------------------------------------------------ | --------------- |
|
|
199
199
|
| `teleport="body"` | Move the element to a CSS selector target (modals, tooltips) | `flow:teleport` |
|
|
200
|
-
| `ref="name"` | Name this element as `$refs.name` for
|
|
200
|
+
| `ref="name"` | Name this element as `$refs.name` for ``this.$`…` `` scripts | `x-ref` |
|
|
201
201
|
|
|
202
202
|
### Alpine plugins
|
|
203
203
|
|
package/docs/flow/routing.md
CHANGED
|
@@ -426,15 +426,27 @@ export class DashboardPage extends Component {
|
|
|
426
426
|
}
|
|
427
427
|
```
|
|
428
428
|
|
|
429
|
-
|
|
429
|
+
### The page title
|
|
430
|
+
|
|
431
|
+
`static title` takes a string, or a function of the component:
|
|
432
|
+
|
|
433
|
+
```typescript
|
|
434
|
+
static title = "Posts";
|
|
435
|
+
static title = (c: PostPage) => `${c.post?.title ?? "Loading"} — My App`;
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
The function form is resolved on the server for every render and every patch, so a title
|
|
439
|
+
that depends on state follows it without an action doing anything:
|
|
430
440
|
|
|
431
441
|
```typescript
|
|
432
442
|
@expose async loadPost(slug: string): Promise<void> {
|
|
433
443
|
this.post = await Post.where("slug", slug).firstOrFail();
|
|
434
|
-
|
|
444
|
+
// the title updates with it — nothing else to call
|
|
435
445
|
}
|
|
436
446
|
```
|
|
437
447
|
|
|
448
|
+
Only the resolved string is sent to the browser; the function stays on the server.
|
|
449
|
+
|
|
438
450
|
For per-render `<head>` content (meta tags, OG tags), use `<Head>` inside `render()`:
|
|
439
451
|
|
|
440
452
|
```tsx
|
package/docs/getting-started.md
CHANGED
|
@@ -11,7 +11,7 @@ provider.
|
|
|
11
11
|
|
|
12
12
|
## Requirements
|
|
13
13
|
|
|
14
|
-
- **Bun** ≥ 1.
|
|
14
|
+
- **Bun** ≥ 1.3.14 — [install](https://bun.sh/docs/installation)
|
|
15
15
|
- A PostgreSQL, MySQL, or SQLite database (SQLite requires nothing extra)
|
|
16
16
|
|
|
17
17
|
## Create a new project
|
|
@@ -47,14 +47,14 @@ bunx create-zerotal my-app --yes # take the defaults for anything uns
|
|
|
47
47
|
bunx create-zerotal --help
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
-
| Flag
|
|
51
|
-
|
|
|
52
|
-
| `-t`, `--template <name>` | `api`, `admin`, `flow`, `react`, `vue`, `minimal`
|
|
53
|
-
| `--db <name>`
|
|
54
|
-
| `-y`, `--yes`
|
|
55
|
-
| `--no-install`
|
|
56
|
-
| `-h`, `--help`
|
|
57
|
-
| `-v`, `--version`
|
|
50
|
+
| Flag | |
|
|
51
|
+
| ------------------------- | -------------------------------------------------- |
|
|
52
|
+
| `-t`, `--template <name>` | `api`, `admin`, `flow`, `react`, `vue`, `minimal` |
|
|
53
|
+
| `--db <name>` | `sqlite`, `postgres`, `mysql` — API template only |
|
|
54
|
+
| `-y`, `--yes` | Take defaults for anything not given; never prompt |
|
|
55
|
+
| `--no-install` | Skip `bun install` |
|
|
56
|
+
| `-h`, `--help` | Usage |
|
|
57
|
+
| `-v`, `--version` | The scaffolder's own version |
|
|
58
58
|
|
|
59
59
|
An answer that is missing and cannot be asked for is an error naming the flag
|
|
60
60
|
that would supply it, and the exit code is non-zero — so a pipeline fails where
|
package/docs/i18n.md
CHANGED
|
@@ -15,7 +15,7 @@ The string you pass to `__()` is the English sentence, not a name for it:
|
|
|
15
15
|
__("Email"); // not __("auth.email")
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
English is the source language, so the source text
|
|
18
|
+
English is the source language, so the source text _is_ the key. That one
|
|
19
19
|
decision removes the `en.json` file, the naming argument, and the class of bug
|
|
20
20
|
where a screen ships reading `auth.email` because somebody mistyped a key.
|
|
21
21
|
|
|
@@ -188,7 +188,7 @@ A component then calls it with no import and no hook:
|
|
|
188
188
|
> TypeScript program, so `@zerotal/i18n`'s declaration already covers these call
|
|
189
189
|
> sites; a second `var __` is a duplicate identifier, not an override.
|
|
190
190
|
|
|
191
|
-
> **Note** — do not sync from `router.on("navigate")`. That event fires
|
|
191
|
+
> **Note** — do not sync from `router.on("navigate")`. That event fires _after_
|
|
192
192
|
> the component swap, so the first render of a new page still carries the
|
|
193
193
|
> previous locale's catalog — visible as one flash of the old language every time
|
|
194
194
|
> someone switches.
|
|
@@ -295,7 +295,7 @@ returns a value listed in `supportedLocales` (otherwise `defaultLocale`):
|
|
|
295
295
|
|
|
296
296
|
- **`query`** — useful for a one-off preview link (`?lang=fr`) or language
|
|
297
297
|
switcher, but it doesn't persist. Put it first so it can override the others.
|
|
298
|
-
- **`cookie`** — the choice that
|
|
298
|
+
- **`cookie`** — the choice that _sticks_. List it when you let users pick a
|
|
299
299
|
language and persist it (see [Overriding the locale](#overriding-the-locale)).
|
|
300
300
|
- **`accept-header`** — the visitor's browser preference; a sensible default when
|
|
301
301
|
no explicit choice has been made. List it last as the fallback.
|
package/docs/routing.md
CHANGED
|
@@ -486,6 +486,17 @@ the checked signature above decorative.
|
|
|
486
486
|
Typed names flow through the helpers built on `route()` too — `redirect().to()`,
|
|
487
487
|
`Url.route()`, `Uri.route()`, and Flow's `redirectRoute()`.
|
|
488
488
|
|
|
489
|
+
The types that checking is built from are exported from `zerotal/routes`, for
|
|
490
|
+
when you write a helper that forwards to `route()` rather than calling it
|
|
491
|
+
directly:
|
|
492
|
+
|
|
493
|
+
| Type | What it holds |
|
|
494
|
+
| ------------------ | ----------------------------------------------------------------------------------------------------------------- |
|
|
495
|
+
| `RouteTarget` | The name a checked helper accepts: `RouteName` once the registry is generated, plain `string` before it. |
|
|
496
|
+
| `RouteArgs<N>` | Everything `route()` takes after the name — params required only when the pattern has one, query always optional. |
|
|
497
|
+
| `RouteParamValues` | The loose param bag the unchecked overload accepts. |
|
|
498
|
+
| `RouteQuery` | Query values. `null` and `undefined` entries drop out, and an array repeats the key. |
|
|
499
|
+
|
|
489
500
|
### route() in the browser
|
|
490
501
|
|
|
491
502
|
`route()` works on the server with no setup: the application installs the table
|
package/docs/support-policy.md
CHANGED
|
@@ -49,11 +49,11 @@ course?", so this is it:
|
|
|
49
49
|
|
|
50
50
|
## Databases
|
|
51
51
|
|
|
52
|
-
| Database | Status
|
|
53
|
-
| ---------- |
|
|
54
|
-
| SQLite | Supported. The default; the full test suite runs against it on every merge.
|
|
55
|
-
| PostgreSQL | Supported
|
|
56
|
-
| MySQL | Experimental. The ORM ships a MySQL dialect and the scaffolder can configure it, but no CI suite runs against a real MySQL server yet — treat it as unverified until it joins the tested matrix.
|
|
52
|
+
| Database | Status |
|
|
53
|
+
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
54
|
+
| SQLite | Supported. The default; the full test suite runs against it on every merge. |
|
|
55
|
+
| PostgreSQL | Supported. A smoke suite runs against a real PostgreSQL 16 on every merge — schema DDL, identity columns, CRUD, type round-trips, row locks and transaction rollback — and the job blocks a merge when it fails. The bulk of the ORM suite still runs on SQLite, so coverage of the Postgres path is narrower than of the default one. |
|
|
56
|
+
| MySQL | Experimental. The ORM ships a MySQL dialect and the scaffolder can configure it, but no CI suite runs against a real MySQL server yet — treat it as unverified until it joins the tested matrix. |
|
|
57
57
|
|
|
58
58
|
Redis-backed drivers (cache, session, queue, broadcasting) build on
|
|
59
59
|
`Bun.RedisClient` and are tested against the protocol surface it provides.
|
|
@@ -67,6 +67,14 @@ dependency order, from CI. Never mix versions across packages.
|
|
|
67
67
|
- **Semantic versioning:** patch for fixes, minor for compatible features, major
|
|
68
68
|
for breaking changes. The [Upgrade Guide](/docs/upgrade) describes the upgrade
|
|
69
69
|
procedure; the [Release Notes](/docs/changelog) list what changed.
|
|
70
|
+
- **One exception, while the 1.x line is young:** a breaking change may land in a
|
|
71
|
+
minor or a patch when leaving it in place would cost more than the migration
|
|
72
|
+
does. It is called out in the release notes as **BREAKING**, with the reason and
|
|
73
|
+
the migration steps, and it is never silent. Two have shipped so far — the
|
|
74
|
+
`ComponentWith` / `BaseModelWith` removal in 1.3.0 and Flow's `socket:` listener
|
|
75
|
+
prefix in 1.7.2. This carve-out is a consequence of the project's age, not a
|
|
76
|
+
standing policy; it will be withdrawn, with a version named here, once adoption
|
|
77
|
+
makes the cost of a break real.
|
|
70
78
|
- **Provenance:** packages are published with npm provenance, so you can verify
|
|
71
79
|
a tarball was built by this repository's release workflow rather than someone's
|
|
72
80
|
laptop.
|
package/docs/upgrade.md
CHANGED
|
@@ -18,6 +18,8 @@ version line:
|
|
|
18
18
|
- **Major** (`X.y.z`) — breaking changes; read the version's section in the
|
|
19
19
|
[Release Notes](/docs/changelog) before upgrading.
|
|
20
20
|
|
|
21
|
+
> **Warning** — while the 1.x line is young, a breaking change may also land in a minor or a patch. It is always labelled **BREAKING** in the [Release Notes](/docs/changelog) with migration steps, and two have shipped so far (1.3.0 and 1.7.2). Read the notes for every version you cross, not only the majors. See [Releases and versioning](/docs/support-policy#releases-and-versioning) for when this carve-out ends.
|
|
22
|
+
|
|
21
23
|
> **Warning** — always upgrade the `@zerotal/*` packages together. Mixing versions across core, ORM, and feature packages leads to type and runtime mismatches.
|
|
22
24
|
|
|
23
25
|
## Upgrade steps
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerotal/arch",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.3",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"maturity": "beta",
|
|
6
6
|
"private": false,
|
|
@@ -35,11 +35,11 @@
|
|
|
35
35
|
"typecheck": "tsc --noEmit"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@zerotal/core": "1.7.
|
|
38
|
+
"@zerotal/core": "1.7.3"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"typescript": "^5.8.0",
|
|
42
|
-
"@zerotal/orm": "1.7.
|
|
42
|
+
"@zerotal/orm": "1.7.3"
|
|
43
43
|
},
|
|
44
44
|
"description": "The Zerotal agent surface — an MCP server that hands coding agents the framework's machine-readable truth: exact API signatures, live routes and schema, version-matched docs, and `zt doctor`.",
|
|
45
45
|
"keywords": [
|