@zerotal/arch 1.7.0 → 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/CHANGELOG.md +57 -0
- package/docs/about.md +4 -4
- package/docs/broadcasting/channels.md +1 -1
- package/docs/broadcasting/client.md +17 -4
- package/docs/broadcasting/events.md +2 -2
- package/docs/broadcasting/index.md +1 -1
- package/docs/changelog.md +106 -4
- package/docs/commands.md +21 -12
- package/docs/contributing.md +2 -2
- package/docs/errors.md +22 -0
- package/docs/flow/components.md +27 -8
- package/docs/flow/decorators.md +32 -16
- package/docs/flow/events.md +53 -19
- package/docs/flow/index.md +18 -7
- package/docs/flow/layouts.md +16 -0
- package/docs/flow/lifecycle.md +31 -16
- package/docs/flow/models.md +284 -0
- package/docs/flow/performance.md +27 -1
- package/docs/flow/references.md +16 -1
- package/docs/flow/routing.md +14 -2
- package/docs/flow/testing.md +32 -0
- package/docs/getting-started.md +26 -1
- package/docs/i18n.md +247 -144
- package/docs/notifications.md +1 -1
- package/docs/queue.md +11 -3
- package/docs/routing.md +89 -7
- package/docs/seeding.md +7 -0
- package/docs/support-policy.md +13 -5
- package/docs/upgrade.md +2 -0
- package/package.json +3 -3
- package/src/install/guidelines.ts +5 -1
- package/src/install/markers.ts +11 -2
- package/src/install/mcpConfig.ts +14 -2
- package/src/probe/topics.ts +19 -4
- package/src/tools/apiSurface.ts +115 -20
- package/src/tools/baselines.ts +33 -4
- package/src/tools/logs.ts +77 -7
- package/src/tools/searchDocs.ts +196 -29
package/docs/flow/events.md
CHANGED
|
@@ -178,7 +178,7 @@ async onPostCreated(data: EventPayload<"post-created">) {
|
|
|
178
178
|
}
|
|
179
179
|
```
|
|
180
180
|
|
|
181
|
-
Adoption is gradual and non-breaking: any event name **not** in the contract stays untyped (a plain optional-payload call), so existing events and `@on("
|
|
181
|
+
Adoption is gradual and non-breaking: any event name **not** in the contract stays untyped (a plain optional-payload call), so existing events and `@on("socket:…")` broadcasts keep compiling — you type the ones you care about, when you care about them.
|
|
182
182
|
|
|
183
183
|
**Runtime guard (optional).** The types cover your own dispatch sites at compile time. For a payload that arrives from an untrusted source — a client-originated dispatch — register a runtime guard; a violating payload then throws from `dispatch` instead of reaching listeners:
|
|
184
184
|
|
|
@@ -231,7 +231,9 @@ When a child component just needs to invoke a parent action, use `$flow.parent`
|
|
|
231
231
|
|
|
232
232
|
## Real-time broadcasting
|
|
233
233
|
|
|
234
|
-
Listen for server-broadcast events over WebSockets with `@on("
|
|
234
|
+
Listen for server-broadcast events over WebSockets with `@on("socket:…")`. When a matching broadcast arrives, the listener method runs server-side exactly like any other action — the component re-renders live.
|
|
235
|
+
|
|
236
|
+
The socket client is bundled into the Flow runtime and created the first time a page declares one of these listeners, so there is no script to add and nothing to publish on `window`. An app that needs a configured client — a different host, its own auth endpoint — assigns `window.Socket` before the runtime loads and that one is used instead. Pages with no such listener open no broadcast connection at all.
|
|
235
237
|
|
|
236
238
|
```typescript
|
|
237
239
|
export class OrderDashboard extends Component {
|
|
@@ -243,7 +245,7 @@ export class OrderDashboard extends Component {
|
|
|
243
245
|
this.recentOrders = await Order.query().orderBy("created_at", "desc").limit(5).get();
|
|
244
246
|
}
|
|
245
247
|
|
|
246
|
-
@on("
|
|
248
|
+
@on("socket:orders,OrderPlaced")
|
|
247
249
|
async onOrderPlaced(payload: { id: number; total: number }): Promise<void> {
|
|
248
250
|
this.orderCount++;
|
|
249
251
|
const order = await Order.findOrFail(payload.id);
|
|
@@ -251,7 +253,7 @@ export class OrderDashboard extends Component {
|
|
|
251
253
|
this.flash(`New order — $${payload.total}`, "success");
|
|
252
254
|
}
|
|
253
255
|
|
|
254
|
-
@on(
|
|
256
|
+
@on((self) => `socket-private:orders.${self.branchId},OrderCancelled`)
|
|
255
257
|
async onOrderCancelled(payload: { id: number }): Promise<void> {
|
|
256
258
|
this.recentOrders = this.recentOrders.filter((o) => o.id !== payload.id);
|
|
257
259
|
this.orderCount = Math.max(0, this.orderCount - 1);
|
|
@@ -274,31 +276,63 @@ export class OrderDashboard extends Component {
|
|
|
274
276
|
|
|
275
277
|
### Channel name formats
|
|
276
278
|
|
|
277
|
-
| Format
|
|
278
|
-
|
|
|
279
|
-
| `
|
|
280
|
-
| `
|
|
281
|
-
| `
|
|
282
|
-
| `
|
|
283
|
-
| `
|
|
284
|
-
| `
|
|
285
|
-
| `
|
|
279
|
+
| Format | Channel type |
|
|
280
|
+
| ------------------------------------ | --------------------------------------- |
|
|
281
|
+
| `socket:channel,Event` | Public channel |
|
|
282
|
+
| `socket-private:channel,Event` | Private channel (requires auth) |
|
|
283
|
+
| `socket-presence:room,joining` | Presence channel — member joined |
|
|
284
|
+
| `socket-presence:room,leaving` | Presence channel — member left |
|
|
285
|
+
| `socket-presence:room,here` | Presence channel — initial member list |
|
|
286
|
+
| `socket:teams.1.threads,MessageSent` | Dot-separated dynamic/nested channel |
|
|
287
|
+
| `socket:scores,.score.submitted` | Custom `broadcastAs` name (leading dot) |
|
|
286
288
|
|
|
287
289
|
The part before the comma is the channel name; the part after is the event name. For presence channels, `joining`, `leaving`, and `here` are the built-in presence event names.
|
|
288
290
|
|
|
291
|
+
### Per-instance channels
|
|
292
|
+
|
|
293
|
+
A channel that names a record — `issues.417`, `orders.8` — cannot be written as a string. The
|
|
294
|
+
decorator's argument is read off the **class**, before any instance exists, so a template literal
|
|
295
|
+
inside a plain string is not interpolated: `@on("socket-private:issues.${this.issueId},CommentPosted")`
|
|
296
|
+
subscribes to a channel whose name contains those characters, and receives nothing.
|
|
297
|
+
|
|
298
|
+
Pass a resolver instead. It is called with the component when the snapshot is built, exactly as
|
|
299
|
+
[`@presence`](#presence--whos-here-multiplayer) and [`@shared`](#shared-state--everyone-converges-multiplayer)
|
|
300
|
+
resolve theirs:
|
|
301
|
+
|
|
302
|
+
```typescript
|
|
303
|
+
export class IssuePage extends Component {
|
|
304
|
+
@locked issue!: Issue;
|
|
305
|
+
@locked comments: Comment[] = [];
|
|
306
|
+
|
|
307
|
+
@on((self) => `socket-private:issues.${self.issue.id},CommentPosted`)
|
|
308
|
+
async onCommentPosted(payload: { comment: Comment }): Promise<void> {
|
|
309
|
+
this.comments = [...this.comments, payload.comment];
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
The resolver runs once per render, after `onMount()`, so it can read anything the component has
|
|
315
|
+
loaded. If it throws — a field it reads is still null, say — that one listener is dropped and the
|
|
316
|
+
page renders without it, rather than the render failing.
|
|
317
|
+
|
|
318
|
+
Resolve the _narrowest_ channel the reader is entitled to. A static `issues` channel with an
|
|
319
|
+
`if (payload.issueId !== this.issue.id) return` in the handler looks equivalent and is not: the
|
|
320
|
+
broadcast still reaches every subscriber's browser, so every reader receives every issue's
|
|
321
|
+
comment bodies and discards them after the fact.
|
|
322
|
+
|
|
289
323
|
### Requirements
|
|
290
324
|
|
|
291
|
-
Broadcasting requires a global `window.
|
|
325
|
+
Broadcasting requires a global `window.Socket` client configured by your application — the first-party `@zerotal/client` `Socket`, or any compatible realtime client. Flow subscribes through it on component mount and unsubscribes on teardown.
|
|
292
326
|
|
|
293
|
-
If `window.
|
|
327
|
+
If `window.Socket` is not present, all `socket:` listeners are silently inert — no errors, no subscriptions attempted.
|
|
294
328
|
|
|
295
329
|
```typescript
|
|
296
330
|
// In your frontend bootstrap (app.ts or similar):
|
|
297
331
|
import { Socket } from "@zerotal/client";
|
|
298
332
|
|
|
299
333
|
// The first-party Socket speaks Zerotal's native broadcast protocol and is a
|
|
300
|
-
// drop-in for `window.
|
|
301
|
-
window.
|
|
334
|
+
// drop-in for `window.Socket` — no external client library or Pusher credentials.
|
|
335
|
+
window.Socket = new Socket();
|
|
302
336
|
```
|
|
303
337
|
|
|
304
338
|
## Presence — who's here (multiplayer)
|
|
@@ -345,7 +379,7 @@ Broadcast.channel("board.[boardId]", (user, boardId) =>
|
|
|
345
379
|
|
|
346
380
|
Whispers are client-only (they ride the presence channel directly), so they're instant and don't count as component round-trips.
|
|
347
381
|
|
|
348
|
-
Like all `
|
|
382
|
+
Like all `socket:` features, presence needs a `window.Socket` client configured (above). Without it, `@presence` props stay empty and whispers are inert — no errors.
|
|
349
383
|
|
|
350
384
|
## Shared state — everyone converges (multiplayer)
|
|
351
385
|
|
|
@@ -370,7 +404,7 @@ The mental model: a `@shared` prop is a **cache of a server-side room value**, n
|
|
|
370
404
|
|
|
371
405
|
Like `@presence`, the channel is resolved on the server (signed in the snapshot, unforgeable) and the prop is server-controlled (`@locked`): clients render it but change it only through `@expose` actions. Authorize the channel in `routes/channels.ts` exactly as for presence.
|
|
372
406
|
|
|
373
|
-
Broadcasting is an **optional peer**. With `window.
|
|
407
|
+
Broadcasting is an **optional peer**. With `window.Socket` and `BroadcastProvider` configured, changes fan out to every open window; without them, `@shared` still converges within a single window's own round-trips, because the room store is server-side either way. For multi-instance deployments, swap the in-process store for a shared backend with `setSharedStore(store)` (any `{ get, set, has }`), e.g. Redis-backed — the convergence logic is unchanged.
|
|
374
408
|
|
|
375
409
|
> v1 semantics are last-write-wins and server-authoritative; `@shared` props should hold plain, serializable data (arrays/objects), like snapshot state generally. The originating window also receives its own change broadcast as an idempotent no-op re-read (self-exclusion is a planned refinement).
|
|
376
410
|
|
package/docs/flow/index.md
CHANGED
|
@@ -135,6 +135,13 @@ component in the project is covered — the Flow scaffold already writes this:
|
|
|
135
135
|
> runtime need a `/** @jsxImportSource … */` comment of their own — which is why
|
|
136
136
|
> `make:flow` writes one into every class it generates.
|
|
137
137
|
|
|
138
|
+
> **Note** — Flow components and `zerotal/view` components do not interoperate, and the failure
|
|
139
|
+
> is a type error rather than a wrong render. The two JSX runtimes produce different element
|
|
140
|
+
> types: a view `FC` returns `SafeHtml` (`{ value }`) and Flow's JSX expects `HtmlNode`
|
|
141
|
+
> (`{ html }`), so using one inside the other is `TS2786: 'Box' cannot be used as a JSX
|
|
142
|
+
component`. A shared component library has to target one runtime; share class-name constants
|
|
143
|
+
> or plain strings across the two instead of components.
|
|
144
|
+
|
|
138
145
|
### Your first component
|
|
139
146
|
|
|
140
147
|
```tsx
|
|
@@ -176,22 +183,26 @@ Router.flow("/counter", CounterPage);
|
|
|
176
183
|
### Reserved member names
|
|
177
184
|
|
|
178
185
|
`Component` brings its own members, and a property of yours that collides with one is a
|
|
179
|
-
type error. It is caught at compile time and the message is specific,
|
|
180
|
-
|
|
181
|
-
|
|
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.
|
|
182
192
|
|
|
183
193
|
The names in use:
|
|
184
194
|
|
|
185
195
|
| Group | Names |
|
|
186
196
|
| ----------------- | --------------------------------------------------------------------------------------------------------------------- |
|
|
187
197
|
| Lifecycle | `onBoot` `onMount` `onHydrate` `onDehydrate` `onRendering` `onRendered` `onUpdate` `onUpdating` `onUpdated` `onError` |
|
|
188
|
-
| Rendering | `render` `layout` `placeholder` `slot` `hasSlot` `child`
|
|
198
|
+
| Rendering | `render` `layout` `placeholder` `slot` `hasSlot` `child` |
|
|
189
199
|
| Actions & state | `bind` `validate` `resetValidation` `errors` `addError` `refresh` `$refresh` `$set` `cancelled` `signal` |
|
|
190
200
|
| Navigation | `redirect` `redirectRoute` `redirectIntended` `currentUrl` `navigateCurrent` |
|
|
191
|
-
| Events & realtime | `dispatch` `dispatchSelf` `dispatchTo` `stream` `client`
|
|
201
|
+
| Events & realtime | `dispatch` `dispatchSelf` `dispatchTo` `stream` `client` `$` |
|
|
192
202
|
| Misc | `flash` `download` `clearDurable` |
|
|
193
203
|
|
|
194
|
-
Anything beginning with `_` is also framework-internal, as
|
|
204
|
+
Anything beginning with `_` is also framework-internal, as are the statics `durable`
|
|
205
|
+
and `title`.
|
|
195
206
|
|
|
196
207
|
If the natural name is taken, the usual fix is a more specific one — `headline`,
|
|
197
208
|
`mediaTitle` — which often reads better than `title` did.
|
|
@@ -563,6 +574,6 @@ Flow is a large surface. Each section below is its own page.
|
|
|
563
574
|
- [Validator](/docs/validator) — the full rule chain behind `@validate` and `this.validate()`.
|
|
564
575
|
- [Middleware](/docs/middleware) — write the guards you attach to Flow routes.
|
|
565
576
|
- [Session](/docs/session) — the store behind `@session` and `SessionMiddleware`.
|
|
566
|
-
- [Broadcasting](/docs/broadcasting) — drive `@on("
|
|
577
|
+
- [Broadcasting](/docs/broadcasting) — drive `@on("socket:…")` real-time updates from the server.
|
|
567
578
|
- [Storage](/docs/storage) — configure the disks that file uploads write to.
|
|
568
579
|
- [Testing](/docs/testing/index) — patterns for the `FlowTest` harness and the rest of the suite.
|
package/docs/flow/layouts.md
CHANGED
|
@@ -143,6 +143,22 @@ override async render() {
|
|
|
143
143
|
}
|
|
144
144
|
```
|
|
145
145
|
|
|
146
|
+
### The document's `lang` is fixed
|
|
147
|
+
|
|
148
|
+
Flow assembles the outer document itself and emits `<html lang="en">`, and a `Layout` has no hook to
|
|
149
|
+
change it. `head` injects into `<head>`; the `<html>` attributes are not reachable from a layout.
|
|
150
|
+
|
|
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 _content_ is correctly marked up —
|
|
153
|
+
but the document still declares English to anything reading the root element, which is wrong for a
|
|
154
|
+
screen reader announcing the page in the wrong voice:
|
|
155
|
+
|
|
156
|
+
```tsx
|
|
157
|
+
override render(slot: HtmlNode) {
|
|
158
|
+
return <div lang={activeLocale()}>{slot}</div>;
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
146
162
|
## Sections
|
|
147
163
|
|
|
148
164
|
A layout owns regions a page cannot reach. When a page needs to put something _there_ — a toolbar
|
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
|
@@ -33,11 +33,37 @@ It's a graceful degrade, not a mode you configure. WebSocket reconnection keeps
|
|
|
33
33
|
|
|
34
34
|
- Each action is a request/response, with no server-pushed frames — so `@task`
|
|
35
35
|
streaming arrives as one batched update rather than token by token.
|
|
36
|
-
- Real-time `@on("
|
|
36
|
+
- Real-time `@on("socket:…")`, `@presence`, and `@shared` broadcasts are not
|
|
37
37
|
delivered, because those ride the separate broadcasting socket.
|
|
38
38
|
|
|
39
39
|
Everything driven by your own actions still works. Nothing is sent over HTTP until the socket has actually failed; the happy path is unchanged.
|
|
40
40
|
|
|
41
|
+
## What forces the runtime fallback
|
|
42
|
+
|
|
43
|
+
Every page is AOT-compiled at boot where it can be. A page the compiler cannot handle still works —
|
|
44
|
+
it renders through the standard runtime instead — and Flow logs a count at startup:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
N of M Flow pages (75%) render through the runtime fallback instead of compiled output.
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Set `ZT_FLOW_COMPILE_LOG=1` to print what blocks each page. The common causes:
|
|
51
|
+
|
|
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
|
+
|
|
59
|
+
**`__()` is the one that matters most.** A translated template is a function call in a text child,
|
|
60
|
+
so a page that translates a single string falls off the fast path — which in an app where `__()`
|
|
61
|
+
is the house style means every page. The cost is normally just speed, but it is not only speed
|
|
62
|
+
under [`cspSafe`](/docs/flow/components#csp-safe-mode): there, every page **must** compile or the
|
|
63
|
+
build fails, so an app using `__()` in its templates cannot run in CSP-safe mode today. If you need
|
|
64
|
+
`cspSafe`, keep translation out of the template — resolve strings in the action or `onMount()` into
|
|
65
|
+
`@locked` properties and render those.
|
|
66
|
+
|
|
41
67
|
## Interaction polish
|
|
42
68
|
|
|
43
69
|
The perceived speed of a server-driven app comes from three things: never showing
|