@zerotal/arch 1.7.2 → 1.7.4

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.
@@ -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()`.
@@ -49,12 +49,12 @@ N of M Flow pages (75%) render through the runtime fallback instead of compiled
49
49
 
50
50
  Set `ZT_FLOW_COMPILE_LOG=1` to print what blocks each page. The common causes:
51
51
 
52
- | Blocker | 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 |
52
+ | Blocker | Fix |
53
+ | -------------------------------------------------------------------- | -------------------------------------------------- |
54
+ | `render()` with more than one `return` | Build the branches into a variable and return once |
55
+ | A function call in a text child — including **`__()`** | See below |
56
+ | An imported child component in `render()` (`<Header/>`) | Inline it, or accept the fallback |
57
+ | `class={someLocalConst}` or a numeric-literal attribute (`rows={3}`) | Use a literal string |
58
58
 
59
59
  **`__()` is the one that matters most.** A translated template is a function call in a text child,
60
60
  so a page that translates a single string falls off the fast path — which in an app where `__()`
@@ -180,7 +180,7 @@ override async render() {
180
180
 
181
181
  > **The payload does not say which container took the drop.** The client reads `flow:sort` off the
182
182
  > container a child was dropped **into** and calls it `(key, index)` — so the destination is
183
- > encoded in *which method runs*, and nowhere else. For a single sortable list that is invisible.
183
+ > encoded in _which method runs_, and nowhere else. For a single sortable list that is invisible.
184
184
  > For dragging **between** containers under one `sortGroup` it means one action per container:
185
185
  >
186
186
  > ```tsx
@@ -189,7 +189,7 @@ override async render() {
189
189
  > ```
190
190
  >
191
191
  > An arrow (`onSort={(k, i) => this.move("todo", k, i)}`) cannot stand in, because the attribute's
192
- > value is used as a method *name* rather than evaluated. `onSort` accepts the name as a string,
192
+ > value is used as a method _name_ rather than evaluated. `onSort` accepts the name as a string,
193
193
  > so the handlers can come from a lookup table keyed by column, but they must be declared members.
194
194
 
195
195
  ### DOM utilities
@@ -197,7 +197,7 @@ override async render() {
197
197
  | You write | Behaviour | Compiles to |
198
198
  | ----------------- | ------------------------------------------------------------ | --------------- |
199
199
  | `teleport="body"` | Move the element to a CSS selector target (modals, tooltips) | `flow:teleport` |
200
- | `ref="name"` | Name this element as `$refs.name` for `this.client()` calls | `x-ref` |
200
+ | `ref="name"` | Name this element as `$refs.name` for ``this.$`…` `` scripts | `x-ref` |
201
201
 
202
202
  ### Alpine plugins
203
203
 
@@ -426,15 +426,27 @@ export class DashboardPage extends Component {
426
426
  }
427
427
  ```
428
428
 
429
- For per-action dynamic titles, use `this.title()` inside an action:
429
+ ### The page title
430
+
431
+ `static title` takes a string, or a function of the component:
432
+
433
+ ```typescript
434
+ static title = "Posts";
435
+ static title = (c: PostPage) => `${c.post?.title ?? "Loading"} — My App`;
436
+ ```
437
+
438
+ The function form is resolved on the server for every render and every patch, so a title
439
+ that depends on state follows it without an action doing anything:
430
440
 
431
441
  ```typescript
432
442
  @expose async loadPost(slug: string): Promise<void> {
433
443
  this.post = await Post.where("slug", slug).firstOrFail();
434
- this.title(`${this.post.title}My App`);
444
+ // the title updates with it nothing else to call
435
445
  }
436
446
  ```
437
447
 
448
+ Only the resolved string is sent to the browser; the function stays on the server.
449
+
438
450
  For per-render `<head>` content (meta tags, OG tags), use `<Head>` inside `render()`:
439
451
 
440
452
  ```tsx
@@ -11,7 +11,7 @@ provider.
11
11
 
12
12
  ## Requirements
13
13
 
14
- - **Bun** ≥ 1.1 — [install](https://bun.sh/docs/installation)
14
+ - **Bun** ≥ 1.3.14 — [install](https://bun.sh/docs/installation)
15
15
  - A PostgreSQL, MySQL, or SQLite database (SQLite requires nothing extra)
16
16
 
17
17
  ## Create a new project
@@ -47,14 +47,14 @@ bunx create-zerotal my-app --yes # take the defaults for anything uns
47
47
  bunx create-zerotal --help
48
48
  ```
49
49
 
50
- | Flag | |
51
- | ---- | --- |
52
- | `-t`, `--template <name>` | `api`, `admin`, `flow`, `react`, `vue`, `minimal` |
53
- | `--db <name>` | `sqlite`, `postgres`, `mysql` — API template only |
54
- | `-y`, `--yes` | Take defaults for anything not given; never prompt |
55
- | `--no-install` | Skip `bun install` |
56
- | `-h`, `--help` | Usage |
57
- | `-v`, `--version` | The scaffolder's own version |
50
+ | Flag | |
51
+ | ------------------------- | -------------------------------------------------- |
52
+ | `-t`, `--template <name>` | `api`, `admin`, `flow`, `react`, `vue`, `minimal` |
53
+ | `--db <name>` | `sqlite`, `postgres`, `mysql` — API template only |
54
+ | `-y`, `--yes` | Take defaults for anything not given; never prompt |
55
+ | `--no-install` | Skip `bun install` |
56
+ | `-h`, `--help` | Usage |
57
+ | `-v`, `--version` | The scaffolder's own version |
58
58
 
59
59
  An answer that is missing and cannot be asked for is an error naming the flag
60
60
  that would supply it, and the exit code is non-zero — so a pipeline fails where
package/docs/i18n.md CHANGED
@@ -15,7 +15,7 @@ The string you pass to `__()` is the English sentence, not a name for it:
15
15
  __("Email"); // not __("auth.email")
16
16
  ```
17
17
 
18
- English is the source language, so the source text *is* the key. That one
18
+ English is the source language, so the source text _is_ the key. That one
19
19
  decision removes the `en.json` file, the naming argument, and the class of bug
20
20
  where a screen ships reading `auth.email` because somebody mistyped a key.
21
21
 
@@ -188,7 +188,7 @@ A component then calls it with no import and no hook:
188
188
  > TypeScript program, so `@zerotal/i18n`'s declaration already covers these call
189
189
  > sites; a second `var __` is a duplicate identifier, not an override.
190
190
 
191
- > **Note** — do not sync from `router.on("navigate")`. That event fires *after*
191
+ > **Note** — do not sync from `router.on("navigate")`. That event fires _after_
192
192
  > the component swap, so the first render of a new page still carries the
193
193
  > previous locale's catalog — visible as one flash of the old language every time
194
194
  > someone switches.
@@ -295,7 +295,7 @@ returns a value listed in `supportedLocales` (otherwise `defaultLocale`):
295
295
 
296
296
  - **`query`** — useful for a one-off preview link (`?lang=fr`) or language
297
297
  switcher, but it doesn't persist. Put it first so it can override the others.
298
- - **`cookie`** — the choice that *sticks*. List it when you let users pick a
298
+ - **`cookie`** — the choice that _sticks_. List it when you let users pick a
299
299
  language and persist it (see [Overriding the locale](#overriding-the-locale)).
300
300
  - **`accept-header`** — the visitor's browser preference; a sensible default when
301
301
  no explicit choice has been made. List it last as the fallback.
@@ -162,7 +162,7 @@ export default function Dashboard({ posts, auth }: Props) {
162
162
  <h1>Dashboard</h1>
163
163
  {auth.user && <p>Welcome back, {auth.user.name}</p>}
164
164
  {posts.map((post) => (
165
- <Link key={post.id} href={`/posts/${post.slug}`}>
165
+ <Link key={post.id} href={route("posts.show", { slug: post.slug })}>
166
166
  {post.title}
167
167
  </Link>
168
168
  ))}
@@ -176,6 +176,11 @@ Note `auth` is available without the controller passing it — see
176
176
  [`make:page`](/docs/inertia/build#generating-a-page) and bundle them with
177
177
  [`inertia:build`](/docs/inertia/build#building-assets).
178
178
 
179
+ `route("posts.show", { slug })` builds the URL from the route's **name** rather than
180
+ hard-coding the path, so renaming a route updates every link to it and a typo fails
181
+ the build. Prefer it over a literal `href` anywhere you link — see
182
+ [Building URLs](/docs/inertia/rendering#building-urls-with-route).
183
+
179
184
  ## Testing
180
185
 
181
186
  Set your suite up once as described in [Testing](/docs/testing). An Inertia route
@@ -339,7 +339,7 @@ export default function Page() {
339
339
  return (
340
340
  <>
341
341
  {flash.success && <div className="toast">{flash.success}</div>}
342
- {auth.user ? <span>{auth.user.name}</span> : <a href="/login">Sign in</a>}
342
+ {auth.user ? <span>{auth.user.name}</span> : <a href={route("login")}>Sign in</a>}
343
343
  </>
344
344
  );
345
345
  }
@@ -147,6 +147,85 @@ array** directly as a shorthand. To use both, pass props third and middleware fo
147
147
  Router.inertia("/admin", "Admin/Dashboard", { title: "Admin" }, [AuthMiddleware]);
148
148
  ```
149
149
 
150
+ ## Building URLs with route()
151
+
152
+ A hard-coded `href="/posts/hello"` is a string nothing checks. Rename the route and
153
+ every link to it keeps compiling and starts 404ing — a bug that surfaces when
154
+ someone clicks, not when someone builds.
155
+
156
+ Name the route instead, and let the URL be derived:
157
+
158
+ ```tsx
159
+ import { Link } from "@inertiajs/react";
160
+
161
+ <Link href={route("posts.show", { slug: post.slug })}>{post.title}</Link>
162
+ <Link href={route("posts.index", {}, { page: 2 })}>Next</Link>
163
+ ```
164
+
165
+ No import for `route` — `defineRoutes()` installs it globally, and the names are
166
+ checked against the same registry your controllers use, so `route("posts.shwo")`
167
+ fails the build. [Routing](/docs/routing#route-in-the-browser) owns the mechanics:
168
+ the generated table, wiring your entry point, typing, and `route.dynamic()` for a
169
+ name only known at runtime.
170
+
171
+ ### Forms submit to a name too
172
+
173
+ A form's action is the same kind of string as a link's `href`, and gets the same
174
+ treatment. `useForm()` and `router` both take a URL, so hand them one that was built
175
+ from the route name:
176
+
177
+ ```tsx
178
+ import { useForm, router } from "@inertiajs/react";
179
+
180
+ export default function Edit({ post }: Props) {
181
+ const form = useForm({ title: post.title, body: post.body });
182
+
183
+ const submit = (e: React.FormEvent) => {
184
+ e.preventDefault();
185
+ form.put(route("posts.update", { slug: post.slug }));
186
+ };
187
+
188
+ const destroy = () => {
189
+ router.delete(route("posts.destroy", { slug: post.slug }));
190
+ };
191
+
192
+ return (
193
+ <form onSubmit={submit}>
194
+ <input value={form.data.title} onChange={(e) => form.setData("title", e.target.value)} />
195
+ {form.errors.title && <span>{form.errors.title}</span>}
196
+ <button disabled={form.processing}>Save</button>
197
+ <button type="button" onClick={destroy}>
198
+ Delete
199
+ </button>
200
+ </form>
201
+ );
202
+ }
203
+ ```
204
+
205
+ The names follow the same convention the router generates: a `POST` is
206
+ `posts.store`, `PUT`/`PATCH` is `posts.update`, `DELETE` is `posts.destroy`. So the
207
+ name in the component and the route the controller is mounted on cannot drift apart
208
+ silently — change the URL and both ends move together.
209
+
210
+ This matters more for a form than for a link. A broken link 404s where someone can
211
+ see it; a form posting to a stale URL fails **after** the user has filled it in, and
212
+ the data goes with it.
213
+
214
+ Build the URL the same way for [Precognition](/docs/inertia/props#precognition), so
215
+ live validation and the real submit cannot end up aimed at different routes — the
216
+ failure there is a form that validates clean and then rejects on save.
217
+
218
+ ### One thing Inertia adds: define the routes in _both_ entries
219
+
220
+ An Inertia page renders twice — once in the SSR process, once in the browser — so a
221
+ component calling `route()` runs in both. A table defined in only one of them throws
222
+ in the other: miss the SSR entry and `POST /__ssr` answers `500` with
223
+ `[Inertia] SSR render failed` in the log, for a page the browser then renders
224
+ perfectly well.
225
+
226
+ Call `defineRoutes(ROUTES)` in your browser entry **and** in your
227
+ [SSR entry](/docs/inertia/ssr). Same static import, same table.
228
+
150
229
  ## Redirects
151
230
 
152
231
  After a non-GET action (a form POST/PUT/DELETE), redirect as usual — return a 302 and
package/docs/routing.md CHANGED
@@ -486,6 +486,17 @@ the checked signature above decorative.
486
486
  Typed names flow through the helpers built on `route()` too — `redirect().to()`,
487
487
  `Url.route()`, `Uri.route()`, and Flow's `redirectRoute()`.
488
488
 
489
+ The types that checking is built from are exported from `zerotal/routes`, for
490
+ when you write a helper that forwards to `route()` rather than calling it
491
+ directly:
492
+
493
+ | Type | What it holds |
494
+ | ------------------ | ----------------------------------------------------------------------------------------------------------------- |
495
+ | `RouteTarget` | The name a checked helper accepts: `RouteName` once the registry is generated, plain `string` before it. |
496
+ | `RouteArgs<N>` | Everything `route()` takes after the name — params required only when the pattern has one, query always optional. |
497
+ | `RouteParamValues` | The loose param bag the unchecked overload accepts. |
498
+ | `RouteQuery` | Query values. `null` and `undefined` entries drop out, and an array repeats the key. |
499
+
489
500
  ### route() in the browser
490
501
 
491
502
  `route()` works on the server with no setup: the application installs the table
@@ -49,11 +49,11 @@ course?", so this is it:
49
49
 
50
50
  ## Databases
51
51
 
52
- | Database | Status |
53
- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
54
- | SQLite | Supported. The default; the full test suite runs against it on every merge. |
55
- | PostgreSQL | Supported, hardening. The ORM suite runs against a real Postgres in CI; remaining dialect gaps are being driven to zero before the job blocks merges. |
56
- | MySQL | Experimental. The ORM ships a MySQL dialect and the scaffolder can configure it, but no CI suite runs against a real MySQL server yet treat it as unverified until it joins the tested matrix. |
52
+ | Database | Status |
53
+ | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
54
+ | SQLite | Supported. The default; the full test suite runs against it on every merge. |
55
+ | PostgreSQL | Supported. A smoke suite runs against a real PostgreSQL 16 on every merge schema DDL and `ALTER`, identity columns, CRUD, type round-trips, unique and NOT NULL enforcement, row locks and transaction rollback — and the job blocks a merge when it fails. The bulk of the ORM suite still runs on SQLite, so the Postgres path is covered more narrowly than the default one. |
56
+ | MySQL | Supported, hardening. The same smoke suite runs against a real MySQL 8 on every merge and blocks on failure. It is newer than the Postgres job and has found one defect already (`string()` was not indexable), so treat MySQL as verified in the paths the suite covers and less proven than PostgreSQL outside them. |
57
57
 
58
58
  Redis-backed drivers (cache, session, queue, broadcasting) build on
59
59
  `Bun.RedisClient` and are tested against the protocol surface it provides.
@@ -67,6 +67,14 @@ dependency order, from CI. Never mix versions across packages.
67
67
  - **Semantic versioning:** patch for fixes, minor for compatible features, major
68
68
  for breaking changes. The [Upgrade Guide](/docs/upgrade) describes the upgrade
69
69
  procedure; the [Release Notes](/docs/changelog) list what changed.
70
+ - **One exception, while the 1.x line is young:** a breaking change may land in a
71
+ minor or a patch when leaving it in place would cost more than the migration
72
+ does. It is called out in the release notes as **BREAKING**, with the reason and
73
+ the migration steps, and it is never silent. Two have shipped so far — the
74
+ `ComponentWith` / `BaseModelWith` removal in 1.3.0 and Flow's `socket:` listener
75
+ prefix in 1.7.2. This carve-out is a consequence of the project's age, not a
76
+ standing policy; it will be withdrawn, with a version named here, once adoption
77
+ makes the cost of a break real.
70
78
  - **Provenance:** packages are published with npm provenance, so you can verify
71
79
  a tarball was built by this repository's release workflow rather than someone's
72
80
  laptop.
@@ -92,6 +100,21 @@ a contract, not a mood:
92
100
  - **experimental** — no compatibility promise. The API may change or the package
93
101
  may be absorbed into another in any release. Build on it with your eyes open.
94
102
 
103
+ ### A label below stable carries a review date
104
+
105
+ An honest "experimental" is useful once and corrosive indefinitely: a package that
106
+ has worn the label for a year is not being cautious, it is unowned. So each one
107
+ below `stable` names the release by which it is reviewed, and the review has three
108
+ outcomes — promote, keep with a new date and the reason, or withdraw.
109
+
110
+ | Package | Now | Reviewed by |
111
+ | --------------- | -------------- | ----------- |
112
+ | `@zerotal/ai` | `experimental` | **1.9.0** |
113
+ | `@zerotal/arch` | `beta` | **1.9.0** |
114
+
115
+ Neither is in the `zerotal` meta-package and nothing `stable` depends on either,
116
+ so the cost of the label falling due is ours and not yours.
117
+
95
118
  A package is never more mature than what it is built on: a stable package whose
96
119
  foundation can change under it is not stable, whatever its own label says. So
97
120
  `@zerotal/admin` and `@zerotal/monitor` cannot pass `@zerotal/flow`, and the
package/docs/upgrade.md CHANGED
@@ -18,6 +18,8 @@ version line:
18
18
  - **Major** (`X.y.z`) — breaking changes; read the version's section in the
19
19
  [Release Notes](/docs/changelog) before upgrading.
20
20
 
21
+ > **Warning** — while the 1.x line is young, a breaking change may also land in a minor or a patch. It is always labelled **BREAKING** in the [Release Notes](/docs/changelog) with migration steps, and two have shipped so far (1.3.0 and 1.7.2). Read the notes for every version you cross, not only the majors. See [Releases and versioning](/docs/support-policy#releases-and-versioning) for when this carve-out ends.
22
+
21
23
  > **Warning** — always upgrade the `@zerotal/*` packages together. Mixing versions across core, ORM, and feature packages leads to type and runtime mismatches.
22
24
 
23
25
  ## Upgrade steps
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/arch",
3
- "version": "1.7.2",
3
+ "version": "1.7.4",
4
4
  "license": "MIT",
5
5
  "maturity": "beta",
6
6
  "private": false,
@@ -35,11 +35,11 @@
35
35
  "typecheck": "tsc --noEmit"
36
36
  },
37
37
  "dependencies": {
38
- "@zerotal/core": "1.7.2"
38
+ "@zerotal/core": "1.7.4"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^5.8.0",
42
- "@zerotal/orm": "1.7.2"
42
+ "@zerotal/orm": "1.7.4"
43
43
  },
44
44
  "description": "The Zerotal agent surface — an MCP server that hands coding agents the framework's machine-readable truth: exact API signatures, live routes and schema, version-matched docs, and `zt doctor`.",
45
45
  "keywords": [