@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/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.
|
|
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.
|
|
@@ -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
|
|
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).
|
|
13
|
-
|
|
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
|
-
//
|
|
38
|
-
|
|
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
|
|
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
|
-
|
|
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/
|
|
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
|
|
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.
|
|
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
|
-
|
|
|
221
|
-
| `
|
|
222
|
-
|
|
|
223
|
-
| `
|
|
224
|
-
| `
|
|
225
|
-
|
|
|
226
|
-
| `
|
|
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
|
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
|
@@ -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
|
-
|
|
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.
|
|
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
|
|
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) => (
|
|
@@ -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
|
|
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}
|
|
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
|
-
|
|
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("
|
|
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("
|
|
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
|
-
//
|
|
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
|
|
416
|
-
|
|
417
|
-
this.
|
|
418
|
-
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} })`;
|
|
419
435
|
|
|
420
436
|
// Trigger a file download in the browser
|
|
421
437
|
this.download("report.csv", csvContent, "text/csv;charset=utf-8");
|