@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 CHANGED
@@ -7,6 +7,63 @@ All notable changes to this package are documented here. The format is
7
7
  **Maturity: beta.** The API is close to final and breaking changes are rare, called out
8
8
  here with migration steps — but a minor release may still contain one.
9
9
 
10
+ ## [Unreleased]
11
+
12
+ ## [1.7.1] — 2026-08-16
13
+
14
+ ### Fixed
15
+
16
+ Faults that only appear once the surface is installed into a real app, found by installing
17
+ it into this repo's own `apps/docs` and calling every tool.
18
+
19
+ - **`search_docs` ranked a generated page above the right answer.** Scoring was raw term
20
+ frequency weighted by field, with no length normalisation — so `components.md`, one
21
+ generated page covering 53 components and long enough to mention nearly everything, came
22
+ first for both "send an email" and "how do I write a test for a controller". Now BM25 over
23
+ a small inverted index built when the corpus is read: length normalisation, inverse
24
+ document frequency and term saturation, with title/description/heading matches scored as
25
+ separate fields _outside_ the saturation, since folded in BM25 flattens a title hit to
26
+ about twice a passing mention. Plus light stemming, so "test" meets "testing", and
27
+ hyphenated terms indexed whole and in parts, so "soft deletes" finds `soft-delete`.
28
+
29
+ Measured on fourteen questions an agent would actually ask, top-1 relevance went from
30
+ roughly three in ten to twelve in fourteen. The remaining two return related pages rather
31
+ than the best one; tuning further against a list that size fits the list, not the corpus.
32
+
33
+ - **`last_error` dropped the error.** The framework logs an exception's class in `error`,
34
+ its trace in `stack` and the request it belongs to in `requestId`. The parser named the
35
+ six fields it knew about and discarded the rest, so the tool whose entire job is saying
36
+ _why_ something failed returned the generic `"Unhandled error"` that wraps the real one —
37
+ a line that says nothing. Entries now carry every field the logger wrote, and
38
+ `last_error` renders the exception, the request id and the trace. `logs` shows the first
39
+ two but not the trace: a stack on each of two hundred entries buries the sequence the
40
+ caller asked for.
41
+
42
+ - **`baselines` reported a ceiling smaller than the command's count, without saying so.**
43
+ The cast baseline ratchets per file and exempts designated boundary modules, so it reads
44
+ 455 where `cast:check` prints 466. A reader comparing the two saw debt that had appeared
45
+ between them. The reading now carries a `note` naming the exempt modules.
46
+
47
+ - **Packages in a workspace were invisible.** `node_modules/@zerotal/*` is a symlink in
48
+ every workspace — this monorepo, `bun link`, any app developed against a checkout — and
49
+ `Bun.Glob` will not descend into one, `followSymlinks` or not. `installedPackages()`
50
+ returned nothing for an app with seventeen packages, so `app_info` reported an empty list
51
+ and `arch:install` wrote generic guidance with none of the per-package sections that are
52
+ the reason it is composed rather than canned. Listed with `readdir` now, which sees the
53
+ link, and read through `Bun.file`, which follows it.
54
+
55
+ - **The generated Markdown failed the formatter.** `arch:install` wrote files that did not
56
+ pass the `prettier --check .` the project it had just installed into already runs. The
57
+ markers now sit on their own lines with blank lines around them — which is correctness
58
+ before it is formatting, since Markdown parses text pressed against an HTML comment as
59
+ part of that raw-HTML block.
60
+
61
+ - **`arch:update` fought the formatter over `.mcp.json`.** `JSON.stringify(…, 2)` expands a
62
+ one-element array where a formatter collapses it, so the command rewrote a file it had no
63
+ change to make to, and the next `prettier --write` put it back. Idempotence is now
64
+ measured on the parsed data: when the config already says what it should, the file is
65
+ returned exactly as it was found, in whatever shape its owner keeps it.
66
+
10
67
  ## [1.7.0] — 2026-08-16
11
68
 
12
69
  ### Added
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.1) and leans on Bun's APIs throughout — `Bun.sql`
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
- - **Reference apps are the best teacher.** Full working apps under `apps/` exercise the
664
- framework end-to-end a Flow-based finance app and a Flow + Auth + ORM starter
665
- are the most complete real-world examples. Read them alongside the docs.
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.
@@ -122,7 +122,7 @@ the wire; a Pusher-protocol client surfaces them to your code under the `pusher:
122
122
 
123
123
  > **Note** — The native `ws`/`redis` drivers use the equivalent
124
124
  > `subscription_succeeded` / `presence:member_added` / `presence:member_removed` events; the
125
- > first-party `Socket` client maps them to Echo's `here`/`joining`/`leaving` callbacks for you.
125
+ > first-party `Socket` client maps them to Socket's `here`/`joining`/`leaving` callbacks for you.
126
126
 
127
127
  ## Typed channels
128
128
 
@@ -9,8 +9,20 @@ description: Subscribe from the browser and react to events as they arrive.
9
9
 
10
10
  `@zerotal/client` ships a small, dependency-free `Socket` that speaks the native broadcast
11
11
  protocol and exposes a familiar realtime-client API — no external client library, and it works
12
- with the lightweight `ws` and `redis` drivers (no Pusher credentials needed). It's also a drop-in
13
- for `window.Echo`, so Flow's [`@on('echo:…')`](/docs/flow/events) listeners work against it.
12
+ with the lightweight `ws` and `redis` drivers (no Pusher credentials needed).
13
+
14
+ **Flow apps need none of this.** The client is bundled into `/__flow/runtime.js` and created the
15
+ first time a page declares a [`@on('socket:…')`](/docs/flow/events) listener, so those listeners
16
+ are live with no script of your own. Read on only if you want a configured client — a different
17
+ host, your own auth endpoint — or if you are subscribing from code that is not a Flow component.
18
+
19
+ > **The package root is fine to import in browser code.** It used to be a bundle error: the root
20
+ > also exports `ClientProvider`, which reaches the CLI commands and `await import("bun")`, and a
21
+ > browser bundler rejects that during resolution — before tree-shaking can discard the half you
22
+ > did not want. `@zerotal/client` now resolves to a browser-safe entry under the `browser`
23
+ > condition, so a bundler gets `Socket`, `ApiClient` and `CircuitBreaker` and none of the
24
+ > server-side exports. `@zerotal/client/Socket` still works and is still the leanest import if
25
+ > `Socket` is all you need.
14
26
 
15
27
  ```ts
16
28
  // in your client code
@@ -34,8 +46,9 @@ socket
34
46
  .leaving((m) => removeOnline(m))
35
47
  .listen("Message", (e) => append(e));
36
48
 
37
- // Use it as Echo for Flow @on('echo:…') listeners:
38
- window.Echo = socket;
49
+ // Only if you need Flow's listeners to use *this* client rather than the bundled
50
+ // one — assign before the runtime loads and it is used as-is:
51
+ window.Socket = socket;
39
52
  ```
40
53
 
41
54
  Private and presence channels are authorized with a **per-subscription HMAC signature** (the same
@@ -64,7 +64,7 @@ Broadcast.send(new OrderShipmentStatusUpdated(order));
64
64
  ```
65
65
 
66
66
  `broadcast(event).toOthers()` excludes the connection that triggered the request (read from the
67
- `X-Socket-ID` header your Echo client sends), so the user who just made an optimistic UI update
67
+ `X-Socket-ID` header your Socket client sends), so the user who just made an optimistic UI update
68
68
  doesn't receive a duplicate.
69
69
 
70
70
  `broadcast()` returns a `PendingBroadcast` — a thenable that sends itself on the next
@@ -181,7 +181,7 @@ The wire event name defaults to `${ModelName}${Event}` (e.g. `OrderUpdated`) and
181
181
 
182
182
  ```ts
183
183
  // in your client code
184
- Echo.private(`orders.${id}`).listen("OrderUpdated", (e) => render(e.order));
184
+ Socket.private(`orders.${id}`).listen("OrderUpdated", (e) => render(e.order));
185
185
  ```
186
186
 
187
187
  ## Next steps
@@ -50,7 +50,7 @@ The provider exposes one HTTP route and one WebSocket upgrade path:
50
50
 
51
51
  - `POST /broadcasting/auth` — the private/presence channel auth endpoint (a real `Router` route).
52
52
  - The WebSocket upgrade is served at the configured `path` (default `/app/ws`) via Bun's
53
- WebSocket handler — it is _not_ a separate `Router` route. Pusher/Echo clients connect to
53
+ WebSocket handler — it is _not_ a separate `Router` route. Pusher/Socket clients connect to
54
54
  `ws://host/app/{appKey}`.
55
55
 
56
56
  ## Configuration
package/docs/changelog.md CHANGED
@@ -17,12 +17,114 @@ 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 and
21
- appear only in major releases.
20
+ - **Changed** — behavior changes; **breaking** ones are called out explicitly, in
21
+ bold, as **BREAKING**.
22
22
  - **Fixed** — bug fixes.
23
23
 
24
- Patch and minor releases are backward compatible. Before taking a **major** release,
25
- read its section here and apply each migration note.
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.
82
+
83
+ ## 1.7.2 — 2026-08-18
84
+
85
+ Realtime that works without being wired up, and three ways a socket could go quiet without
86
+ saying so.
87
+
88
+ ### Changed
89
+
90
+ - **BREAKING — Flow's `@on` broadcast listeners use a `socket:` prefix.** `echo:`,
91
+ `echo-private:` and `echo-presence:` are now `socket:`, `socket-private:` and
92
+ `socket-presence:`; the browser global is `window.Socket`, not `window.Echo`. There is no
93
+ alias — an unrenamed listener never matches, and never subscribes.
94
+
95
+ ```diff
96
+ - @on("echo-private:issues.5,CommentPosted")
97
+ + @on("socket-private:issues.5,CommentPosted")
98
+ ```
99
+
100
+ Shipped in a patch release deliberately, on the judgement that the old prefix has no
101
+ meaningful use in the wild. If you are on it, the upgrade is a find-and-replace of `echo:`
102
+ → `socket:` in your `@on` listeners and `window.Echo` → `window.Socket` in any client code.
103
+
104
+ ### Added
105
+
106
+ - **Flow bundles the socket client into its runtime.** A page that declares a `socket:`
107
+ listener is live with no script of your own. Flow apps own no bundle entry, so the contract
108
+ used to be "publish `window.Socket` yourself" — and when you didn't, the listeners were
109
+ _silently inert_: no error, no warning, no subscription, so a live feature with no script
110
+ looked exactly like a live feature that was never written. An app that needs a configured
111
+ client still assigns `window.Socket` before the runtime loads and that one is used as-is; a
112
+ page with no listeners opens no connection at all.
113
+
114
+ ### Fixed
115
+
116
+ - **A patch no longer writes back into a file input.** A file input's `value` belongs to the
117
+ user agent, and assigning anything but `""` throws `InvalidStateError`. The write was legal
118
+ while the bound property was empty and threw on the very patch carrying an upload's result
119
+ — and the throw escaped the frame handler, so the DOM never updated _and_ the action's ack
120
+ never resolved. Since frames are chained per component, every later action queued behind a
121
+ promise that would never settle: the page rendered correctly and ignored every click for
122
+ the rest of its life.
123
+
124
+ - **WebSocket connections get an explicit 120s `idleTimeout`.** Bun closes an idle socket
125
+ after 10 seconds; the client pings every 30. A connection that was merely quiet got cut
126
+ before it had reason to speak, taking its channel subscriptions with it — so anyone who had
127
+ been reading a page for more than ten seconds silently stopped receiving broadcasts.
26
128
 
27
129
  ## 1.7.0 — 2026-08-16
28
130
 
package/docs/commands.md CHANGED
@@ -210,23 +210,32 @@ running, restarting, or has given up:
210
210
  ─────────────────────────────────────────────────
211
211
  GET / 200 4ms
212
212
  GET /posts 200 11ms
213
- 1-9 tab · ←/→ cycle · r restart · c clear · / search · t time · s stream · q quit
213
+ 1-9 tab · ←/→ cycle · ↑/↓ scroll · r restart · c clear · / search · t time · s stream · q quit
214
214
  ```
215
215
 
216
- | Key | Does |
217
- | ------------- | ---------------------------------------------------------------- |
218
- | `1`–`9` | Select that tab |
219
- | `←` `→` `Tab` | Cycle through tabs |
220
- | `r` | Restart the focused process |
221
- | `c` | Clear the focused tab's output |
222
- | `/` | Search within the focused tab (`Enter` keeps it, `Esc` drops it) |
223
- | `t` | Toggle per-line timestamps |
224
- | `s` | Switch to stream mode |
225
- | `PgUp` `PgDn` | Scroll the focused tab |
226
- | `q` | Quit stops every process and restores your shell |
216
+ | Key | Does |
217
+ | ------------- | ----------------------------------------------------------------- |
218
+ | `1`–`9` | Select that tab |
219
+ | `←` `→` `Tab` | Cycle through tabs |
220
+ | `↑` `↓` | Scroll the focused tab a line at a time — as does the mouse wheel |
221
+ | `PgUp` `PgDn` | Scroll it a screen at a time |
222
+ | `Home` `End` | Jump to the oldest line, or back to the newest |
223
+ | `r` | Restart the focused process |
224
+ | `c` | Clear the focused tab's output |
225
+ | `/` | Search within the focused tab (`Enter` keeps it, `Esc` drops it) |
226
+ | `t` | Toggle per-line timestamps |
227
+ | `s` | Switch to stream mode |
228
+ | `q` | Quit — stops every process and restores your shell |
227
229
 
228
230
  Scrollback belongs to the deck rather than to your terminal, which is what makes
229
231
  per-tab history and search possible. It keeps the last 5,000 lines per process.
232
+ Your terminal's own scrollbar does nothing while the deck is up — it has no
233
+ history to move, because the deck holds it all. Use the keys above (or the
234
+ wheel, which the terminal sends the deck as `↑`/`↓`).
235
+
236
+ Scrolling up parks the view where you left it: the process behind the tab keeps
237
+ printing, but what you stopped to read stays on screen until you scroll back
238
+ down to the newest line.
230
239
 
231
240
  **A process that dies never takes the server with it.** It restarts on its own —
232
241
  three times, backing off between attempts — and if it still will not start, that
@@ -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.1 is required. Node.js is not supported; Zerotal uses `Bun.sql`, `Bun.CryptoHasher`, `Bun.build`, and other Bun-native APIs throughout.
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/ # example apps used for end-to-end testing and the docs site
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
@@ -29,6 +29,13 @@ import { Link } from "@zerotal/flow";
29
29
  Posts
30
30
  </Link>;
31
31
 
32
+ {
33
+ /* Prefetch on pointer-down instead — for a row in a long list */
34
+ }
35
+ <Link href="/posts/42" down>
36
+ One post
37
+ </Link>;
38
+
32
39
  {
33
40
  /* Disable automatic data-current (e.g. always-active home links) */
34
41
  }
@@ -37,6 +44,14 @@ import { Link } from "@zerotal/flow";
37
44
  </Link>;
38
45
  ```
39
46
 
47
+ Choose between `hover` and `down` by how many of the link there are. `hover` prefetches after a
48
+ short dwell, which is free speed on a handful of stable links — a navigation rail, a breadcrumb.
49
+ On a dense list it inverts: the pointer crosses every row between where it is and where it is
50
+ going, so scrolling a hundred-row table asks the server for a hundred pages nobody chose. `down`
51
+ fires on `pointerdown` instead — once, on the link the reader has committed to, and still ahead
52
+ of the click by however long the button is held. Both may be set; the target is fetched once and
53
+ cached either way.
54
+
40
55
  `data-current` matches by **prefix** — a link to `/posts` stays active on `/posts/42` — which is what you want for a section parent. For an index link that should be active only on its own exact URL (an "Overview" tab that shouldn't light up on the section's sub-pages), add `exact`:
41
56
 
42
57
  ```tsx
@@ -456,6 +471,14 @@ import { Tabs } from "@zerotal/flow";
456
471
 
457
472
  `<Tabs>` emits `role="tablist"` / `"tab"` / `"tabpanel"` with roving arrow-key navigation.
458
473
 
474
+ > **Note** — selection is client-only and not addressable. The active tab lives in Alpine state,
475
+ > always starts on the first item, and there is no prop to bind or read it, so `?tab=settings`
476
+ > cannot be made to work and the back button does not step between panels. The tab strip's own
477
+ > classes are fixed (`border-gray-800`, `text-white`, `border-indigo-500`); only the outer wrapper
478
+ > takes `class`, so on a light surface the selected tab is white on white. For a tab set that has
479
+ > to be linkable or has to match a design system, drive the panels from a `@url` property and
480
+ > write the strip out — it is a dozen lines, and you keep both.
481
+
459
482
  ## File upload component
460
483
 
461
484
  ### FileUpload + FileUploads mixin
@@ -466,25 +489,21 @@ A dropzone bound to an `@expose` property. Choosing a file POSTs the bytes to `/
466
489
  import { Component, expose, FileUpload, FileUploads, TemporaryUploadedFile } from "@zerotal/flow";
467
490
 
468
491
  export class AvatarPage extends Component.using(FileUploads) {
492
+ @locked user!: User; // /users/:user/avatar — the record, already found
469
493
  @expose photo: TemporaryUploadedFile | null = null;
470
- @locked photoUrl: string = "";
471
-
472
- override async onMount() {
473
- const user = await User.find(this.userId);
474
- this.photoUrl = user?.avatarUrl ?? "";
475
- }
476
494
 
477
495
  @expose async save(): Promise<void> {
478
496
  if (!this.photo) return;
479
497
  const path = await this.photo.store("avatars"); // moves to permanent storage
480
- await User.query().where("id", this.userId).update({ avatarUrl: path });
498
+ this.user.avatarUrl = path;
499
+ await this.user.save();
481
500
  this.flash("Avatar updated.", "success");
482
501
  }
483
502
 
484
503
  override async render() {
485
504
  return (
486
505
  <div class="space-y-4">
487
- {this.photoUrl && <img src={this.photoUrl} class="h-24 w-24 rounded-full" />}
506
+ {this.user.avatarUrl && <img src={this.user.avatarUrl} class="h-24 w-24 rounded-full" />}
488
507
 
489
508
  <FileUpload bind={this.photo} accept="image/*" maxSize="5mb" />
490
509
 
@@ -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 data loaded in `onMount()` that the server controls: model results, user info, computed totals, child props from the parent:
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?.name}</h1>
69
+ <h1>Posts by {this.user.name}</h1>
69
70
  <p>{this.total} posts</p>
70
71
  <ul>
71
72
  {this.posts.map((p) => (
@@ -133,9 +134,13 @@ You can also pass rules directly to `this.validate()` — they override the deco
133
134
 
134
135
  ### Real-time validation
135
136
 
136
- When a `@validate` field is bound with `flow:model.live` (or `.blur`), each change is validated on
137
+ When a `@validate` field is bound `live` (or `blur`), each change is validated on
137
138
  the server as it arrives. The field's error appears (and clears)
138
- as the user edits, with no action call and without affecting any other field:
139
+ as the user edits, with no action call and without affecting any other field.
140
+
141
+ > **Note** — `live` is the prop you write; `flow:model.live` is what it compiles to. The compiled
142
+ > form is not writable in TSX: the `.` in an attribute name is a parse error (`TS1003`), so
143
+ > copying it out of the emitted HTML into a component will not build.
139
144
 
140
145
  ```tsx
141
146
  @expose @validate((rule) => rule.required().email()) email = "";
@@ -143,7 +148,7 @@ as the user edits, with no action call and without affecting any other field:
143
148
  async render() {
144
149
  return (
145
150
  <div>
146
- <input type="email" value={this.email} flow:model.live />
151
+ <input type="email" value={this.email} live />
147
152
  <span error={this.errors.email} />
148
153
  </div>
149
154
  );
@@ -265,7 +270,8 @@ An `@expose`d method that runs on the server but **skips the re-render cycle**.
265
270
  }
266
271
 
267
272
  @expose @renderless async archivePost(): Promise<void> {
268
- await Post.where("id", this.postId).update({ status: "archived" });
273
+ this.post.status = "archived"; // this.post came from the route
274
+ await this.post.save();
269
275
  this.redirect("/posts");
270
276
  }
271
277
  ```
@@ -289,13 +295,22 @@ async refreshUser(data: { userId: number }): Promise<void> {
289
295
  }
290
296
 
291
297
  // Listen for real-time WebSocket broadcasts (see Events doc for channel formats)
292
- @on("echo:orders,OrderPlaced")
298
+ @on("socket:orders,OrderPlaced")
293
299
  async onOrderPlaced(payload: { id: number }): Promise<void> {
294
300
  this.orderCount++;
295
301
  this.flash("New order received!", "success");
296
302
  }
303
+
304
+ // A channel naming a record needs a resolver — the string form is read off the
305
+ // class, so `"socket:orders.${this.id},…"` would subscribe to that literal text.
306
+ @on((self) => `socket-private:orders.${self.orderId},OrderCancelled`)
307
+ async onOrderCancelled(payload: { id: number }): Promise<void> {
308
+ this.orderCount--;
309
+ }
297
310
  ```
298
311
 
312
+ Both forms are a `ListenerName`: the event string itself, or a resolver handed the component instance that returns one.
313
+
299
314
  See [Events & Broadcasting](/docs/flow/events) for dispatch methods, targeting, broadcasting, and native event integration.
300
315
 
301
316
  ## The @reactive decorator
@@ -385,7 +400,8 @@ See [Layouts & Composition](/docs/flow/layouts#two-way-props) for the full `@mod
385
400
  | `@expose method` | Callable from the browser via WebSocket |
386
401
  | `@expose @renderless method` | Callable from browser; skips re-render cycle |
387
402
  | `@on("event") method` | Listens for cross-component events (auto-exposed) |
388
- | `@on("echo:channel,Event") method` | Listens for real-time server broadcasts |
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) |
389
405
  | `@reactive prop` | Child prop; parent re-pushes on change, child re-renders |
390
406
  | `@modelable prop` | Two-way child prop; writes from child flow back to parent |
391
407
 
@@ -409,13 +425,13 @@ this.redirectIntended("/dashboard"); // back to where AuthMiddleware intercepted
409
425
  // Force onMount() to re-run this round-trip (useful for reloading stale data)
410
426
  this.refresh();
411
427
 
412
- // Update the document title in the browser tab
413
- this.title("Edit post — My App");
428
+ // The document title is `static title` on the class, not an action — see Routing.
414
429
 
415
- // Run raw JavaScript in the browser after the DOM patch is applied
416
- this.client("$refs.titleInput.focus()");
417
- this.client("window.scrollTo({ top: 0, behavior: 'smooth' })");
418
- this.client(`$dispatch('toast', { message: 'Done!' })`);
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} })`;
419
435
 
420
436
  // Trigger a file download in the browser
421
437
  this.download("report.csv", csvContent, "text/csv;charset=utf-8");