@zerotal/arch 1.10.0 → 1.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/ai.md CHANGED
@@ -333,6 +333,88 @@ the part that can be wrong. Whether the model's prose is good is not a unit test
333
333
  `ai.refuse()` makes the next call decline, which is worth exercising deliberately: a
334
334
  refusal is an HTTP 200, so that handling path is the one most likely never to have run.
335
335
 
336
+ ### An empty string is an answer
337
+
338
+ `required` treats `""` as absent, which is right for a form — an empty text input
339
+ submits `""`, and a user who typed nothing supplied nothing. It is **not** how
340
+ structured output works. There, `""` is the conventional way to say _"this field does
341
+ not apply"_, and it is what a prompt naturally asks for:
342
+
343
+ > A month must be YYYY-MM. Use an empty string when the question names no month.
344
+
345
+ So `rule.string()` accepts `""` on the AI path, and only there. Absence is still a
346
+ failure — the field has to be present — and every other constraint still applies:
347
+
348
+ ```typescript fragment
349
+ // in a service
350
+ await Ai.object(prompt, (rule) => ({
351
+ month: rule.string(), // "" is an answer; missing is not
352
+ category: rule.string().min(3), // "" fails min(3), because that is your rule
353
+ score: rule.number(), // "" is a malformed answer, not a convention
354
+ }));
355
+ ```
356
+
357
+ That difference is worth knowing because the failure it caused was silent: an app's
358
+ questions mostly named no month, the model returned `""` in three seconds every time,
359
+ the answer was rejected as malformed, and the page said _"either no model is
360
+ configured, or it was not about your money"_ — while a model was configured and had
361
+ answered.
362
+
363
+ ### `AiFake` checks what you script it with
364
+
365
+ Pass the same schema to the fake that production passes, and a canned object that the
366
+ real driver would reject fails the test instead:
367
+
368
+ ```typescript fragment
369
+ // in a test
370
+ const ai = AiFake.install();
371
+ ai.respondWithObject({ month: "" });
372
+
373
+ // Validated against this schema, exactly as a driver would validate a real answer.
374
+ await service.answer("what did I spend");
375
+ ```
376
+
377
+ This matters more than it sounds. A fake that returns whatever it is handed makes a
378
+ suite _less_ informative than no suite: eleven tests passed on a `{ month: "" }` the
379
+ live path rejected every time, so the feature shipped green and answered nothing. The
380
+ permissive fake is what made the schema bug invisible; they were the same defect from
381
+ both ends.
382
+
383
+ Omit the schema and nothing is checked, because there is nothing to check against.
384
+
385
+ ### Deciding whether to give up: `transient`
386
+
387
+ Every `AiError` carries `transient` — `true` for _this call failed_, `false` for _this
388
+ machine cannot do this_:
389
+
390
+ ```typescript fragment
391
+ // in a service
392
+ try {
393
+ return await Ai.object(prompt, schema);
394
+ } catch (error) {
395
+ if (error instanceof AiError && !error.transient) this.disabled = true;
396
+ return null;
397
+ }
398
+ ```
399
+
400
+ A service calling a model per row needs that latch, or a machine with no API key pays
401
+ the driver's timeout per row, per merchant, per page load — eight seconds times twelve
402
+ merchants is ninety seconds of blank page.
403
+
404
+ | Permanent — stop asking | Transient — try again |
405
+ | ------------------------------------------- | --------------------------------------- |
406
+ | `AiConfigError`, `AiDriverUnavailableError` | `AiRateLimitError`, `AiSpendLimitError` |
407
+ | `UnknownAiDriverError` | `AiSchemaError`, `AiRefusedError` |
408
+ | `AiRequestError` with a 4xx | `AiRequestError` with 5xx, 408 or 429 |
409
+ | | `AiAgentLimitError`, `AiCancelledError` |
410
+
411
+ **`AiSchemaError` is transient**, and that is the one worth checking your own code
412
+ against. Sampling is not deterministic, so a model that shaped one answer badly may
413
+ shape the next correctly — an app classified it as permanent and would have disabled
414
+ two features on their first imperfect reply. The permissive mistake in this direction
415
+ is unrecoverable, because every call site already treats "no answer" as normal, so a
416
+ feature that switches itself off never says so.
417
+
336
418
  ## Observability
337
419
 
338
420
  Every generation emits `AiGenerated` on the framework event bus, and a decline also
package/docs/changelog.md CHANGED
@@ -27,6 +27,215 @@ the section for every version you cross and apply its migration notes, not only
27
27
  majors. [Releases and versioning](/docs/support-policy#releases-and-versioning) explains
28
28
  when that carve-out ends.
29
29
 
30
+ ## 1.11.1 — 2026-08-31
31
+
32
+ Two things the framework could not do, both reported by teams who had already
33
+ worked around them.
34
+
35
+ A patch, not a minor: nothing here breaks. Under
36
+ [the versioning scheme](/docs/upgrade#versioning) a minor is reserved for a
37
+ breaking change and a patch carries everything else, features included — so this
38
+ is safe to take from any 1.11.x.
39
+
40
+ ### Added
41
+
42
+ - **`zt version`** — which Zerotal, which Bun, which app.
43
+
44
+ ```
45
+ Zerotal 1.11.1
46
+ Bun 1.3.14
47
+ App my-app 0.1.0
48
+ ```
49
+
50
+ It was an unknown command, so the version had to be dug out of `package.json` or
51
+ `node_modules` — both of which report what is _installed_ rather than what is
52
+ _running_, and those differ for any process that has been up since before an
53
+ upgrade. It reports the running one.
54
+
55
+ `--version` and `-v` answer earlier still, ahead of the runtime check, the config
56
+ load and the app import, because those are the things someone is asking the version
57
+ _about_: a config that no longer validates and an app that will not boot are the two
58
+ moments the question stops being idle. A version flag that only works when
59
+ everything else already works answers a question nobody has.
60
+
61
+ Add `--json` for a script, and prefer `zt --version --json` over
62
+ `zt version --json` there — the application's boot log is written to stdout, so the
63
+ second form puts a log line ahead of the JSON while the first never boots at all.
64
+ The output carries no colour, unlike every other command's, because it gets pasted
65
+ into bug reports and piped into parsers more than it is read on a terminal.
66
+
67
+ - **`MailMessage.header()` and `MailPayload.headers`** — set a header the mail driver
68
+ does not build itself.
69
+
70
+ ```ts
71
+ new MailMessage()
72
+ .subject("Your weekly digest")
73
+ .header("List-Unsubscribe", `<https://app.test/unsubscribe/${token}>`)
74
+ .header("List-Unsubscribe-Post", "List-Unsubscribe=One-Click");
75
+ ```
76
+
77
+ `MailPayload` had `to`, `from`, `subject`, `text`, `html`, `cc`, `bcc`, `replyTo`
78
+ and `attachments`, and no way to add anything else — so a team that wanted
79
+ `List-Unsubscribe` had to patch a vendored copy of the package, and shipped a footer
80
+ link instead.
81
+
82
+ Those are not substitutes for one another. Gmail and Yahoo draw their native
83
+ unsubscribe control from the header, and a recipient who cannot find a control marks
84
+ the message as spam instead — a judgement that attaches to the sending domain and
85
+ degrades delivery of everything else it sends, including the mail people asked for.
86
+ Send `List-Unsubscribe-Post` alongside it: alone, the first leaves a link to follow,
87
+ and only the pair produces the one-click control both providers now expect.
88
+
89
+ Wired through all three drivers. SMTP writes them into the message, Resend sends
90
+ them as the API's `headers` object, and the log driver prints them — that last one
91
+ deliberately, because the reason to set a header is that a mail client does
92
+ something with it, and the log driver is where that gets checked before anything is
93
+ sent for real.
94
+
95
+ Names the drivers build themselves are refused rather than sent twice: a second
96
+ `Subject` is an ambiguous message, not an override, and which copy a client believes
97
+ is its own business. The list is exported as `RESERVED_MAIL_HEADERS`, with
98
+ `resolveHeaders()` beside it for anyone writing a custom transport. CR and LF in a
99
+ value are folded to a space — left raw they end the header and let the remainder be
100
+ read as further headers, which is how a `Bcc` arrives courtesy of whoever supplied a
101
+ tracking ID.
102
+
103
+ ## 1.11.0 — 2026-08-30
104
+
105
+ Two production reports, from teams taking apps live on 1.9.0 — one shipping a
106
+ household-finance app to a VPS, one migrating a webmail platform from Flow to Inertia
107
+ and cutting it over to live traffic. Between them, nineteen findings.
108
+
109
+ The character of the list is the thing worth naming. Almost none of it is a crash.
110
+ Most of it fails silently or fails open: a release gate that always passes, a
111
+ `cascadeOnDelete` that deletes nothing, an `.env.example` carrying the key the project
112
+ actually runs with, a fake that agrees with whatever it is handed. Building an app
113
+ finds loud bugs quickly because somebody is watching. Deploying one finds the quiet
114
+ ones, months later, when nobody is.
115
+
116
+ **This is the first release under the versioning scheme in
117
+ [the upgrade guide](/docs/upgrade#versioning): a minor carries breaking changes, a
118
+ patch never does, and majors are annual.** So a `^1.10.0` range will pull this in.
119
+ Read the two items below before you take it.
120
+
121
+ ### Two things to do before upgrading
122
+
123
+ - **SQLite now enforces foreign keys.** Run `bun zt db:check-foreign-keys` first. It
124
+ lists any row whose parent is missing — legal before, a constraint violation now —
125
+ and exits non-zero, so a release script can gate on it.
126
+ - **If you have ever renamed a migration file**, `migrate` will now stop rather than
127
+ re-run it. That is the intended behaviour and the message says what to do; see
128
+ [the upgrade guide](/docs/upgrade#1-10-to-1-11).
129
+
130
+ ### Changed — BREAKING
131
+
132
+ - **SQLite enforces foreign keys.** `database.sqlite.foreignKeys` defaults to `true`.
133
+ SQLite ignores foreign keys unless the connection asks it not to, and it is the only
134
+ supported dialect that does — so `constrained()` and `cascadeOnDelete()` in a
135
+ migration described behaviour the database would not perform. Deleting a parent left
136
+ its children, silently, and every child had to be removed by hand in the right order
137
+ by application code that remembered to. An app's data-erasure path swept fifteen
138
+ tables and missed three, two of them holding uploaded files, so an account erasure
139
+ left the paperwork on disk. `zt db:check-foreign-keys` and `zt doctor` both report
140
+ the rows that enforcement would now reject; `sqlite: { foreignKeys: false }` takes
141
+ the old behaviour back while you fix them.
142
+
143
+ - **A renumbered migration is refused rather than re-run.** A migration is recorded
144
+ under its filename, so renaming one made an applied migration look pending — the
145
+ runner tried it again and failed on `table already exists`, a failed boot whose
146
+ error named a table rather than the rename. An app renumbered `001_` to `0001_` to
147
+ match this framework's own scaffold convention and would have made all nine of its
148
+ production migrations look unrun. `migrate` now recognises that shape, refuses, and
149
+ prints both spellings and the fix.
150
+
151
+ ### Fixed
152
+
153
+ - **`.env.example` no longer ships the key the project runs with.** Both files got the
154
+ same rendered content, so every scaffolded project committed a live, working
155
+ `APP_KEY` — `.gitignore` covers `.env` and not `.env.example`. And
156
+ `cp .env.example .env` is the first line of every deployment guide, so the published
157
+ key went on to sign production sessions. No strength check can catch it: as a string
158
+ the value is perfectly strong.
159
+
160
+ - **`.gitignore` covers the SQLite sidecars.** `*.sqlite` does not match
161
+ `db.sqlite-wal` or `db.sqlite-shm`, and WAL mode is on by default, so both exist in
162
+ every project and the write-ahead log holds rows not yet checkpointed. An app found
163
+ both in its first commit on a public host.
164
+
165
+ - **A command can fail without throwing.** `CommandRunner` ran `process.exit(0)` the
166
+ moment `run()` returned and never read `process.exitCode` — the idiomatic way to
167
+ fail a CLI without an exception. A release gate printed six blockers, set the code,
168
+ and exited `0`. `zt deploy` gates on the same value, so its own preflight had the
169
+ hole too: a gate that could not fail, failing open.
170
+
171
+ - **A Bun the project never asked for is a warning, not a refusal.**
172
+ `bun-plugin-tailwind` declares `bun` as a required peer, so `bun install` fetches a
173
+ second runtime and the guard refused to boot. An app took two outages on it. The
174
+ guard now asks whether the project _declared_ `bun`; if not, it warns and names both
175
+ the fix that works and the one that cannot.
176
+
177
+ - **SMTP submission and TLS verification.** STARTTLS on 587 completed its handshake
178
+ and sent nothing — a write issued before the handshake finishes is dropped. And
179
+ `rejectUnauthorized` is not enforced by the runtime on either transport, so TLS was
180
+ encrypted and would have accepted that encryption from anyone in the path.
181
+
182
+ - **Migration names no longer carry the platform that recorded them.** `Bun.Glob`
183
+ yields native separators, so on Windows the whole joined path went into the
184
+ `migrations` table. A database moved between platforms re-ran every migration.
185
+
186
+ - **React SSR emits the page's `<Head>` tags**, and `ctx.session.intended()` reads the
187
+ URL `AuthMiddleware` stored — the two APIs used different session keys, so an app
188
+ that mixed them was silently sent to `/` after every sign-in.
189
+
190
+ - **An empty string is an answer.** `required` treats `""` as absent, which is right
191
+ for a form and wrong for structured model output, where `""` is how a prompt asks a
192
+ model to say "this does not apply". A whole feature returned nothing because of it —
193
+ and shipped green, because `AiFake` never checked its canned object against the
194
+ schema. One half made the mistake; the other made it invisible.
195
+
196
+ - **`MonitorStore` no longer overwrites its own defaults with `undefined`**, and
197
+ `zt inertia:build` fails when it produces no files rather than serving a page with
198
+ no script.
199
+
200
+ ### Added
201
+
202
+ - **`zt db:check-foreign-keys`** — the rows enforcement would reject, by table and
203
+ rowid, exiting non-zero.
204
+ - **`Migration.id`** — a declared identity, so renaming a migration file is free.
205
+ - **`@zerotal/inertia/testing`'s `renderPage()`**, and a page-render test in the React
206
+ scaffold. An app shipped a blank page with 614 passing tests: every one asserted a
207
+ value or a status code, so a page could throw on its first paint and the suite
208
+ stayed green.
209
+ - **`AiError.transient`** — `true` for _this call failed_, `false` for _this machine
210
+ cannot do this_, so a service can latch itself off without classifying eleven error
211
+ classes by hand.
212
+ - **`assertRedirectContains()`**, and **`assertRedirect()` now compares paths
213
+ exactly** — it used `includes()`, so `assertRedirect("/login")` passed on
214
+ `/login-as-someone-else`.
215
+ - **`database.sqlite.foreignKeys`**, a doctor check for a `notifications` table that
216
+ is not the framework's, and a doctor check for a production `mail.driver` of `log`.
217
+
218
+ ### Changed
219
+
220
+ - **`config/session.ts` is scaffolded environment-aware**, so the first production
221
+ deploy no longer fails on the config validator's (correct) refusal.
222
+ - **Tailwind and its plugin move to `dependencies`** and the plugin is pinned — a
223
+ `--production` install that then builds on the server had neither.
224
+ - **The notification database channel is built on first use**, so an app that never
225
+ routes there never touches the table.
226
+ - **`@column({ type: "integer" })` compiles.** The object form took six type names
227
+ while the string form took twelve.
228
+ - **`--success` meets WCAG AA** at the contrast it is actually drawn at.
229
+
230
+ ### Documented
231
+
232
+ - [Persistent layouts](/docs/inertia/rendering#persistent-layouts), which failed only
233
+ in a browser and were documented nowhere;
234
+ [which Inertia redirects are covered](/docs/inertia/middleware#which-redirects-are-covered);
235
+ [pages render](/docs/testing#pages-render); the middleware names the framework
236
+ occupies; why `X-Forwarded-For` is counted from the right; and how to authenticate a
237
+ test when identity is not a row.
238
+
30
239
  ## 1.10.0 — 2026-08-30
31
240
 
32
241
  A second report from the team building on Zerotal, and the things it found. Most of this
package/docs/commands.md CHANGED
@@ -310,6 +310,7 @@ Packages register their own — see
310
310
 
311
311
  | Command | Description |
312
312
  | ---------------------- | ------------------------------------------------------ |
313
+ | `bun zt version` | Show the Zerotal, Bun and app versions |
313
314
  | `bun zt route:list` | List all registered routes with methods and middleware |
314
315
  | `bun zt route:types` | Write `types/routes.generated.ts` (`--check` in CI) |
315
316
  | `bun zt doctor` | Check the app for silent misconfigurations |
@@ -317,6 +318,36 @@ Packages register their own — see
317
318
  | `bun zt lint:packages` | Check every workspace package against convention rules |
318
319
  | `bun zt upgrade` | Apply the codemods for a version upgrade |
319
320
 
321
+ #### Which version am I on?
322
+
323
+ `bun zt version` prints the framework, the runtime and the app:
324
+
325
+ ```
326
+ Zerotal 1.11.0
327
+ Bun 1.3.14
328
+ App my-app 0.1.0
329
+ ```
330
+
331
+ It reports the version that is **running**, which is not always the version that is
332
+ installed — a long-running server holds the code it booted with, so an upgrade lands
333
+ on disk without reaching it.
334
+
335
+ `--version` and `-v` answer the same question without booting the application:
336
+
337
+ ```bash
338
+ bun zt --version
339
+ bun zt --version --json # for a script
340
+ ```
341
+
342
+ Prefer the flag form in scripts, for two reasons. It still answers when the app does
343
+ not boot — a config that no longer validates is exactly when you want to know which
344
+ version you are on. And it is the form whose output can be piped: the application's
345
+ boot log is written to stdout, so `bun zt version --json` carries a log line ahead of
346
+ the JSON, while `bun zt --version --json` never boots and emits nothing else.
347
+
348
+ If `node_modules` contains a second Bun, the report names it. Nothing executes that
349
+ copy — it arrives as a peer dependency — but it is the one an install would use.
350
+
320
351
  ### Upgrading between versions
321
352
 
322
353
  `bun zt upgrade --to <version>` applies the codemods a version gap calls for —
@@ -112,6 +112,73 @@ models with unloaded relations** — eager-load what the page needs (`.with("aut
112
112
  or map to a plain shape. Shared props (`auth.user`) are already reduced to scalars
113
113
  for you; see [Shared Props](/docs/inertia/props).
114
114
 
115
+ ## Persistent layouts
116
+
117
+ A page can name a layout that survives navigation — the shell is not unmounted and
118
+ remounted between visits, so its state, scroll position and any open panel stay put:
119
+
120
+ ```tsx fragment
121
+ // resources/js/pages/mail.tsx
122
+ import MailLayout from "../Layouts/MailLayout";
123
+
124
+ export default function Mail({ messages }) {
125
+ return <MessageList messages={messages} />;
126
+ }
127
+
128
+ Mail.layout = (page) => <MailLayout>{page}</MailLayout>;
129
+ ```
130
+
131
+ ### The callback is handed the page element, not the page props
132
+
133
+ This is the one thing to get right, because getting it wrong fails in a way nothing
134
+ on the server can see:
135
+
136
+ ```tsx fragment
137
+ // WRONG — `page.props` is undefined. Compiles, 200s, blank screen.
138
+ Mail.layout = (page) => <MailLayout search={page.props.search}>{page}</MailLayout>;
139
+ ```
140
+
141
+ The argument is the rendered page **element**. It has no `props.search`, so this
142
+ throws `Cannot read properties of undefined` on the first paint — in the browser,
143
+ after the response has been sent. The route still answers `200`, the Inertia payload
144
+ is still correct, and a server-side test still passes. The user gets a white page.
145
+
146
+ Read props with `usePage()` inside a layout component instead:
147
+
148
+ ```tsx fragment
149
+ // resources/js/pages/mail.tsx
150
+ import { usePage } from "@inertiajs/react";
151
+ import type { SharedProps } from "../types";
152
+
153
+ function MailLayout({ children }) {
154
+ const { props } = usePage<SharedProps & { search?: string }>();
155
+ return <SuiteLayout search={props.search}>{children}</SuiteLayout>;
156
+ }
157
+
158
+ Mail.layout = (page) => <MailLayout>{page}</MailLayout>;
159
+ ```
160
+
161
+ `usePage()` reads the same page object the server sent, from context, and works at
162
+ any depth — so a layout five components down needs nothing threaded to it.
163
+
164
+ > **Why the wrong form typechecks.** `@inertiajs/react` types the callback's argument
165
+ > loosely enough that reaching for `.props` is not a compile error, and a cast to get
166
+ > past a complaint makes it worse. The check that catches it is
167
+ > [rendering the page in a test](/docs/testing#pages-render) — the scaffold ships one,
168
+ > and it is the only thing in a normal suite that builds the component tree at all.
169
+
170
+ ### One layout for several pages
171
+
172
+ Assign the same callback, or export it from the layout module and reuse it:
173
+
174
+ ```tsx fragment
175
+ // resources/js/Layouts/MailLayout.tsx
176
+ export const withMailLayout = (page: ReactNode) => <MailLayout>{page}</MailLayout>;
177
+
178
+ // resources/js/pages/mail.tsx
179
+ Mail.layout = withMailLayout;
180
+ ```
181
+
115
182
  ## First load vs. navigation
116
183
 
117
184
  `inertia()` branches on the `X-Inertia` request header:
@@ -397,6 +397,16 @@ The `onDelete` / `onUpdate` actions are `"CASCADE"`, `"SET NULL"`, `"RESTRICT"`,
397
397
  `"NO ACTION"`. Shorthands `cascadeOnDelete()`, `nullOnDelete()`, and `restrictOnDelete()`
398
398
  read more fluently.
399
399
 
400
+ > **SQLite only enforces these when asked**, and it is the only supported dialect that
401
+ > behaves that way — `database.sqlite.foreignKeys` defaults to `true` and sets
402
+ > `PRAGMA foreign_keys = ON` on every connection. Turn it off and the declarations
403
+ > above become comments: deleting a parent leaves its children, silently, and every
404
+ > child has to be removed by hand in the right order. An app's data-erasure path
405
+ > missed three tables that way, two of them holding uploaded files.
406
+ >
407
+ > On a database that ran without enforcement, `bun zt db:check-foreign-keys` lists any
408
+ > rows that would now be rejected. Postgres and MySQL always enforce.
409
+
400
410
  ## Soft deletes
401
411
 
402
412
  ```typescript fragment
@@ -390,6 +390,47 @@ async toMail(_n: Notifiable): Promise<MailMessage> {
390
390
  }
391
391
  ```
392
392
 
393
+ #### Custom headers, and the unsubscribe button
394
+
395
+ `header(name, value)` sets a header the driver does not build itself. The one this
396
+ exists for is `List-Unsubscribe`:
397
+
398
+ ```ts fragment
399
+ // in a Notification
400
+ toMail(n: Notifiable): MailMessage {
401
+ return new MailMessage()
402
+ .subject("Your weekly digest")
403
+ .line("Here is what happened this week.")
404
+ .header("List-Unsubscribe", `<https://app.test/unsubscribe/${n.unsubscribeToken}>`)
405
+ .header("List-Unsubscribe-Post", "List-Unsubscribe=One-Click");
406
+ }
407
+ ```
408
+
409
+ Gmail and Yahoo draw their native unsubscribe control — the one beside the sender's
410
+ name, not the one in your footer — from that header, and there is no other way to ask
411
+ for it. Send both headers, not just the first: alone, `List-Unsubscribe` leaves the
412
+ recipient a link to follow, and only the pair produces a control that resolves in one
413
+ press. The URL must accept a `POST` with no body and unsubscribe on the spot, with no
414
+ confirmation page.
415
+
416
+ It is worth more than the footer link it duplicates. A recipient who cannot find the
417
+ control marks the message as spam instead, and that judgement attaches to the sending
418
+ domain and affects delivery of everything else it sends — including the mail people do
419
+ want. The two paths out of a mailing list are not equivalent for the sender.
420
+
421
+ Header names the drivers build themselves are refused rather than sent twice, because
422
+ a second `Subject` is an ambiguous message rather than an override. Those names are
423
+ `RESERVED_MAIL_HEADERS`, and the error names the `MailMessage` method to use instead
424
+ where there is one. The check runs when you set the header, so the throw carries the
425
+ stack of the code that wrote it rather than of a queue worker three hops away — and
426
+ again in the driver, since a `MailPayload` can be built without ever passing through
427
+ `MailMessage`.
428
+
429
+ Values are folded to a single line: a CR or LF in a header value ends the header and
430
+ lets the rest be read as further headers, which is how a `Bcc` gets added by someone
431
+ who was only supposed to be supplying a tracking ID. Writing a custom transport?
432
+ Call `resolveHeaders()` on the payload's headers and it does both checks for you.
433
+
393
434
  ### database
394
435
 
395
436
  Implement `toDatabase(notifiable)` returning a plain object. The notification is
package/docs/orm/index.md CHANGED
@@ -202,12 +202,21 @@ import { column } from "@zerotal/orm";
202
202
 
203
203
  Shorthands map to: `string`, `text`, `integer`, `number`, `float`, `boolean`, `datetime`, `date`, `json`, `array`, `encrypted`, `encrypted:json`. See [Casts & Mutators](/docs/orm/casts) for the full cast reference.
204
204
 
205
- A shorthand is not the same as `type`. `type` is only the storage type —
206
- `string`, `text`, `number`, `boolean`, `datetime`, `json` — so `{ type: "integer" }`
207
- and `{ type: "encrypted" }` are both errors. The shorthands that look like types
208
- (`integer`, `float`, `encrypted`) are type-and-cast pairs: `@column("integer")` is
209
- `{ type: "number", cast: "integer" }`, and `@column("encrypted")` is
210
- `{ type: "text", cast: "encrypted" }`.
205
+ **`type` takes either vocabulary.** The _storage_ types are `string`, `text`,
206
+ `number`, `boolean`, `datetime` and `json` — what schema generation emits. The
207
+ shorthands that look like types (`integer`, `float`, `date`, `encrypted`) are
208
+ type-and-cast pairs, and writing one as a `type` resolves it the same way the string
209
+ form does:
210
+
211
+ ```typescript fragment
212
+ // in a model class body
213
+ @column({ type: "integer", default: 0 }) retries!: number; // → { type: "number", cast: "integer" }
214
+ @column({ type: "encrypted", nullable: true }) idNumber?: string; // → { type: "text", cast: "encrypted" }
215
+ ```
216
+
217
+ `{ type: "integer" }` used to be an error while `@column("integer")` compiled, so the
218
+ vocabulary halved exactly when a column needed `default`, `nullable` or `unique` —
219
+ which is most real columns. An explicit `cast` alongside a shorthand still wins.
211
220
 
212
221
  `string` is a bounded VARCHAR and `text` is the unbounded TEXT type — a distinction that matters on Postgres and MySQL, where a long body in a `VARCHAR(255)` is an error rather than a slow column.
213
222
 
@@ -64,17 +64,21 @@ All `@zerotal/*` packages and `create-zerotal` share **one version line and
64
64
  publish lockstep** — a release publishes every package at the same version, in
65
65
  dependency order, from CI. Never mix versions across packages.
66
66
 
67
- - **Semantic versioning:** patch for fixes, minor for compatible features, major
68
- for breaking changes. The [Upgrade Guide](/docs/upgrade) describes the upgrade
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. Three have shipped so far the
67
+ - **What the numbers mean:** a **patch** is anything that does not break
68
+ a fix, and a feature too. A **minor** carries a breaking change. A **major** is
69
+ an annual consolidation, cut each July. The
70
+ [Upgrade Guide](/docs/upgrade#versioning) explains why the framework is versioned
71
+ this way and describes the upgrade procedure; the
72
+ [Release Notes](/docs/changelog) list what changed.
73
+ - **What that costs you:** a caret range crosses a minor, so a project on
74
+ `^1.10.0` takes 1.11.0 and its breaking change without being asked. Pin with a
75
+ tilde if you would rather cross a minor deliberately.
76
+ - **A break is never silent.** Every one is called out in the release notes as
77
+ **BREAKING**, with the reason and the migration steps, and the version gets its
78
+ own section in the Upgrade Guide. Four have shipped so far — the
74
79
  `ComponentWith` / `BaseModelWith` removal in 1.3.0, Flow's `socket:` listener
75
- prefix in 1.7.2, and the removal of Flow's `this.title(…)` in 1.7.3. 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.
80
+ prefix in 1.7.2, the removal of Flow's `this.title(…)` in 1.7.3, and SQLite
81
+ foreign-key enforcement in 1.11.0.
78
82
  - **Provenance:** packages are published with npm provenance, so you can verify
79
83
  a tarball was built by this repository's release workflow rather than someone's
80
84
  laptop.
@@ -171,6 +171,21 @@ await testApp.withSession({ locale: "fr", flash: "saved" }).get("/profile");
171
171
  `session.secret` and `session.cookie` from your config. `withSession()` preserves
172
172
  any `user_id` already set by `actingAs()`.
173
173
 
174
+ > **No users table?** `withSession()` is the whole answer, and it is the one to reach
175
+ > for when identity is not a row — an app whose login _is_ an IMAP login has no user
176
+ > to hand `actingAs()`. Seed whatever your app reads from the session and the request
177
+ > is authenticated:
178
+ >
179
+ > ```typescript fragment
180
+ > // in a test
181
+ > await testApp.withSession({ mail_wallet: { primary: "a@example.test" } }).get("/mail");
182
+ > ```
183
+ >
184
+ > Reaching past this to the session driver is the wrong layer and does not work —
185
+ > `driver.write()` is not a method, and `saveSession()` wants an id and a `Response`
186
+ > you do not have yet. Both of these encode through the app's _own_ driver, so the
187
+ > cookie always matches the format the app will read.
188
+
174
189
  ### Headers and redirects
175
190
 
176
191
  ```typescript fragment
@@ -396,42 +411,43 @@ expect(ctx.response?.status).toBe(200);
396
411
 
397
412
  ### TestResponse
398
413
 
399
- | Member | Signature | Description |
400
- | ----------------------------------------------------------------------------------- | ----------------------------------------- | ---------------------------------------------------- |
401
- | `assertStatus` | `assertStatus(expected: number): this` | Assert the status code. |
402
- | `assertOk` / `assertCreated` / `assertNoContent` | `(): this` | Assert `200` / `201` / `204`. |
403
- | `assertSuccessful` | `(): this` | Assert any `2xx`. |
404
- | `assertMovedPermanently` | `(): this` | Assert `301`. |
405
- | `assertUnauthorized` / `assertForbidden` / `assertNotFound` / `assertUnprocessable` | `(): this` | Assert `401` / `403` / `404` / `422`. |
406
- | `assertServerError` | `(): this` | Assert `500`. |
407
- | `assertRedirect` | `assertRedirect(url: string): this` | Assert a `3xx` whose `Location` contains `url`. |
408
- | `assertHeader` | `assertHeader(name, value?): this` | Assert a header is present (and contains `value`). |
409
- | `assertHeaderMissing` | `assertHeaderMissing(name): this` | Assert a header is absent. |
410
- | `assertJson` | `assertJson(expected): this` | Assert each key in `expected` matches the JSON body. |
411
- | `assertJsonPath` | `assertJsonPath(path, expected): this` | Assert a dot-notation path in the JSON body. |
412
- | `assertJsonCount` | `assertJsonCount(count, key?): this` | Assert an array length at the body or `key`. |
413
- | `assertSee` / `assertBodyContains` | `(needle): this` | Assert the body contains `needle`. |
414
- | `assertDontSee` | `assertDontSee(needle): this` | Assert the body does not contain `needle`. |
415
- | `assertSeeText` / `assertDontSeeText` | `(needle): this` | The same, against the body with its tags stripped. |
416
- | `assertInvalid` | `assertInvalid(fields?): this` | Assert validation failed, optionally on `fields`. |
417
- | `assertValid` | `assertValid(fields?): this` | Assert validation did not fail. |
418
- | `validationErrors` | `(): Record<string, string[]> \| null` | The errors, from the body or the session. |
419
- | `assertAuthenticated` | `(): this` | Assert the session holds a `user_id`. |
420
- | `assertAuthenticatedAs` | `assertAuthenticatedAs(user \| id): this` | Assert that specific user is signed in. |
421
- | `assertGuest` | `(): this` | Assert nobody is signed in. |
422
- | `assertCookie` | `assertCookie(name, value?): this` | Assert a `Set-Cookie` (and optional value). |
423
- | `assertCookieMissing` | `assertCookieMissing(name): this` | Assert no such cookie is set. |
424
- | `assertSessionHas` | `assertSessionHas(key, value?): this` | Assert the session contains `key`. |
425
- | `assertSessionMissing` | `assertSessionMissing(key): this` | Assert the session lacks `key`. |
426
- | `assertSessionHasErrors` / `assertSessionHasNoErrors` | `(fields?): this` | Assert flashed validation errors. |
427
- | `session` | `(): Record<string, unknown> \| null` | The decoded session. |
428
- | `assertInertia` | `assertInertia(component?, props?): this` | Assert the Inertia page and a partial prop match. |
429
- | `assertInertiaProp` | `assertInertiaProp(key, value?): this` | Assert a single Inertia prop. |
430
- | `inertia` | `(): InertiaPage \| null` | The Inertia page object, from either wire shape. |
431
- | `exception` | `(): unknown` | The exception the request raised, if any. |
432
- | `json` | `json<T>(): T` | Parse and return the full JSON body. |
433
- | `text` | `text(): string` | Return the body as text. |
434
- | `status` / `ok` / `headers` | getters | The underlying `Response` status, `ok`, and headers. |
414
+ | Member | Signature | Description |
415
+ | ----------------------------------------------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------ |
416
+ | `assertStatus` | `assertStatus(expected: number): this` | Assert the status code. |
417
+ | `assertOk` / `assertCreated` / `assertNoContent` | `(): this` | Assert `200` / `201` / `204`. |
418
+ | `assertSuccessful` | `(): this` | Assert any `2xx`. |
419
+ | `assertMovedPermanently` | `(): this` | Assert `301`. |
420
+ | `assertUnauthorized` / `assertForbidden` / `assertNotFound` / `assertUnprocessable` | `(): this` | Assert `401` / `403` / `404` / `422`. |
421
+ | `assertServerError` | `(): this` | Assert `500`. |
422
+ | `assertRedirect` | `assertRedirect(url: string): this` | Assert a `3xx` whose `Location` path equals `url`. |
423
+ | `assertRedirectContains` | `assertRedirectContains(fragment: string): this` | Assert a `3xx` whose `Location` merely contains `fragment` — for a signed URL. |
424
+ | `assertHeader` | `assertHeader(name, value?): this` | Assert a header is present (and contains `value`). |
425
+ | `assertHeaderMissing` | `assertHeaderMissing(name): this` | Assert a header is absent. |
426
+ | `assertJson` | `assertJson(expected): this` | Assert each key in `expected` matches the JSON body. |
427
+ | `assertJsonPath` | `assertJsonPath(path, expected): this` | Assert a dot-notation path in the JSON body. |
428
+ | `assertJsonCount` | `assertJsonCount(count, key?): this` | Assert an array length at the body or `key`. |
429
+ | `assertSee` / `assertBodyContains` | `(needle): this` | Assert the body contains `needle`. |
430
+ | `assertDontSee` | `assertDontSee(needle): this` | Assert the body does not contain `needle`. |
431
+ | `assertSeeText` / `assertDontSeeText` | `(needle): this` | The same, against the body with its tags stripped. |
432
+ | `assertInvalid` | `assertInvalid(fields?): this` | Assert validation failed, optionally on `fields`. |
433
+ | `assertValid` | `assertValid(fields?): this` | Assert validation did not fail. |
434
+ | `validationErrors` | `(): Record<string, string[]> \| null` | The errors, from the body or the session. |
435
+ | `assertAuthenticated` | `(): this` | Assert the session holds a `user_id`. |
436
+ | `assertAuthenticatedAs` | `assertAuthenticatedAs(user \| id): this` | Assert that specific user is signed in. |
437
+ | `assertGuest` | `(): this` | Assert nobody is signed in. |
438
+ | `assertCookie` | `assertCookie(name, value?): this` | Assert a `Set-Cookie` (and optional value). |
439
+ | `assertCookieMissing` | `assertCookieMissing(name): this` | Assert no such cookie is set. |
440
+ | `assertSessionHas` | `assertSessionHas(key, value?): this` | Assert the session contains `key`. |
441
+ | `assertSessionMissing` | `assertSessionMissing(key): this` | Assert the session lacks `key`. |
442
+ | `assertSessionHasErrors` / `assertSessionHasNoErrors` | `(fields?): this` | Assert flashed validation errors. |
443
+ | `session` | `(): Record<string, unknown> \| null` | The decoded session. |
444
+ | `assertInertia` | `assertInertia(component?, props?): this` | Assert the Inertia page and a partial prop match. |
445
+ | `assertInertiaProp` | `assertInertiaProp(key, value?): this` | Assert a single Inertia prop. |
446
+ | `inertia` | `(): InertiaPage \| null` | The Inertia page object, from either wire shape. |
447
+ | `exception` | `(): unknown` | The exception the request raised, if any. |
448
+ | `json` | `json<T>(): T` | Parse and return the full JSON body. |
449
+ | `text` | `text(): string` | Return the body as text. |
450
+ | `status` / `ok` / `headers` | getters | The underlying `Response` status, `ok`, and headers. |
435
451
 
436
452
  ## Next steps
437
453
 
@@ -209,6 +209,46 @@ observers, global scopes, and state-machine callbacks, plus framework event
209
209
  subscriptions. `createTestApp()` and `testApp.close()` call it for you, so suites
210
210
  using those helpers don't need the explicit `afterEach`.
211
211
 
212
+ ## Pages render
213
+
214
+ A test that asserts a status code or an Inertia payload proves the _server_ did its
215
+ job. It proves nothing about the component, and a page can throw on its first paint
216
+ while every such test passes — the route answers `200`, the payload is correct, and
217
+ the failure happens in a browser the suite never opened.
218
+
219
+ An app shipped a blank page to production with **614 passing tests** exactly that way:
220
+ a [layout callback](/docs/inertia/rendering#persistent-layouts) read `page.props`,
221
+ which the callback is not given.
222
+
223
+ `renderPage()` builds the component tree and lets whatever it throws escape:
224
+
225
+ ```typescript fragment
226
+ // tests/pages.test.ts
227
+ import { renderPage } from "@zerotal/inertia/testing";
228
+ import Profile from "../resources/js/pages/profile";
229
+
230
+ test("profile builds", async () => {
231
+ await renderPage(Profile, { title: "Profile" }, { shared: SHARED });
232
+ });
233
+ ```
234
+
235
+ It renders through Inertia's own `<App>`, so `usePage()`, `<Head>` and a persistent
236
+ layout all behave as they do in the browser — the layout is resolved and rendered
237
+ too, which is the case worth catching.
238
+
239
+ Two things to know:
240
+
241
+ - **Seed the shared props.** A component that destructures `auth` or `flash` throws
242
+ without them. That is a real failure and rarely the one you are testing for, so
243
+ pass the shape your `Inertia.share()` actually sends.
244
+ - **It is not a DOM.** `useEffect` does not run and nothing clicks; this is
245
+ `renderToString`. For behaviour after paint, use
246
+ [the browser harness](/docs/testing/browser).
247
+
248
+ The React scaffold ships one of these covering every page it generates. Add a line
249
+ when you add a page — the cost is one line and the bug it catches is a white screen
250
+ your users find first.
251
+
212
252
  ## `bun test` vs `bun zt test`
213
253
 
214
254
  Both run the same files. `bun zt test` is a wrapper that sets up three things Bun's
@@ -271,6 +311,7 @@ preload runs the floor check as a warning.
271
311
  | `runtimeBelowFloor` | `runtimeBelowFloor(cwd?): RuntimeFloor \| null` | Is this process below that floor? `null` when it is met or none is declared. |
272
312
  | `runtimeBelowFloorMessage` | `runtimeBelowFloorMessage(floor): string` | The explanation to print — both versions, the manifest, and the way out. |
273
313
  | `installedBunVersion` | `installedBunVersion(cwd): { version, manifest } \| null` | The Bun in `node_modules`, if the project installs one as a package. |
314
+ | `declaresBunDependency` | `declaresBunDependency(cwd): boolean` | Whether the project _asked_ for that package, or acquired it as a transitive peer. |
274
315
  | `runtimeMismatch` | `runtimeMismatch(cwd?): RuntimeMismatch \| null` | Does the running Bun differ from the installed one? Compared exactly — a patch is a binary. |
275
316
  | `runtimeMismatchMessage` | `runtimeMismatchMessage(mismatch): string` | The explanation for that one. |
276
317
  | `runtimeMismatchAllowed` | `runtimeMismatchAllowed(): boolean` | Whether `ZT_ALLOW_RUNTIME_MISMATCH` is set. |
package/docs/upgrade.md CHANGED
@@ -10,15 +10,28 @@ what changed in each version, see the [Release Notes](/docs/changelog).
10
10
 
11
11
  ## Versioning
12
12
 
13
- Zerotal follows semantic versioning across its `@zerotal/*` packages, which share a
14
- version line:
15
-
16
- - **Patch** (`x.y.Z`) — bug fixes, safe to take anytime.
17
- - **Minor** (`x.Y.z`) — new features, backward compatible.
18
- - **Major** (`X.y.z`) breaking changes; read the version's section in the
19
- [Release Notes](/docs/changelog) before upgrading.
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. Read the notes for every version you cross, not only the majors. See [Releases and versioning](/docs/support-policy#releases-and-versioning) for which ones have shipped and when this carve-out ends.
13
+ Zerotal's `@zerotal/*` packages share one version line, and what each number means
14
+ is set by how much the framework still moves in a year rather than by the letter of
15
+ semver:
16
+
17
+ - **Patch** (`x.y.Z`) — anything that does not break. Fixes, and features too.
18
+ Safe to take at any time.
19
+ - **Minor** (`x.Y.z`) a breaking change. Always labelled **BREAKING** in the
20
+ [Release Notes](/docs/changelog), with the reason and the migration steps, and
21
+ given its own section on this page.
22
+ - **Major** (`X.y.z`) — an annual consolidation, cut each July. The next is 2.0, in
23
+ July 2027.
24
+
25
+ Why not strict semver: a framework this young corrects itself often, and under
26
+ strict semver every correction is a major. A version line that reaches 9.0 in its
27
+ first year tells a reader nothing about how much has changed — only that the
28
+ project is willing to break things, which the release notes already say far more
29
+ precisely. Keeping the major for a yearly line in the sand leaves it meaning
30
+ something, and puts the work where it is useful: reading the notes for each minor.
31
+
32
+ > **Warning** — **a caret range crosses a minor.** `"zerotal": "^1.10.0"` will
33
+ > install 1.11.0, and its breaking change, without asking. Read the notes for every
34
+ > minor you cross, or pin with a tilde (`~1.10.0`) and cross them deliberately.
22
35
 
23
36
  > **Warning** — always upgrade the `@zerotal/*` packages together. Mixing versions across core, ORM, and feature packages leads to type and runtime mismatches.
24
37
 
@@ -217,6 +230,65 @@ worth thirty seconds of checking if it does.
217
230
  Nothing to change if you already have it as a dependency, which every React Inertia app
218
231
  does.
219
232
 
233
+ ## 1.10 to 1.11
234
+
235
+ Two changes to how the database is treated. Both are **BREAKING** in the narrow sense
236
+ that a working app can stop working on upgrade, and both refuse loudly rather than
237
+ doing something quiet.
238
+
239
+ 1. **SQLite enforces foreign keys.** `database.sqlite.foreignKeys` defaults to `true`,
240
+ so `PRAGMA foreign_keys = ON` is set on every connection. Until now SQLite ignored
241
+ them, which meant `constrained()` and `cascadeOnDelete()` in your migrations
242
+ described behaviour the database would not perform — deleting a parent left its
243
+ children, silently.
244
+
245
+ The risk is data you already have. A child row whose parent is missing was legal
246
+ without enforcement and is a constraint violation with it, so a write touching one
247
+ now fails. Find them before deploying:
248
+
249
+ ```bash fragment
250
+ bun zt db:check-foreign-keys
251
+ ```
252
+
253
+ It lists every offending row by table and rowid and exits non-zero, so a release
254
+ script can gate on it. `zt doctor` reports the same thing. Delete them or repoint
255
+ them at a parent that exists.
256
+
257
+ To take the release without dealing with it yet:
258
+
259
+ ```ts fragment
260
+ // config/database.ts
261
+ export default DatabaseConfig({ sqlite: { foreignKeys: false } });
262
+ ```
263
+
264
+ Take that override back off afterwards. With it in place `cascadeOnDelete()` is a
265
+ comment.
266
+
267
+ 2. **A renumbered migration is refused rather than re-run.** A migration is recorded
268
+ under its filename, so renaming one makes an applied migration look pending — the
269
+ runner tries it again and fails on `table already exists`. Renumbering `001_` to
270
+ `0001_` to match the scaffold's convention is exactly the kind of tidying that
271
+ causes it, and it takes every migration with it.
272
+
273
+ `migrate` now recognises that shape and stops:
274
+
275
+ ```
276
+ "0001_create_users" looks like "001_create_users", which has already run — the
277
+ same migration renumbered rather than a new one.
278
+ ```
279
+
280
+ If you meant to rename, pin the identity to what the database already holds and the
281
+ filename is then free:
282
+
283
+ ```ts fragment
284
+ export default class CreateUsers extends Migration {
285
+ static override id = "001_create_users";
286
+ }
287
+ ```
288
+
289
+ If it really is a new migration, give it a name that does not collide once the
290
+ leading digits are removed.
291
+
220
292
  ## The managed zt.ts
221
293
 
222
294
  `zt.ts` is framework-managed — the header says _do not modify_. If a release
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/arch",
3
- "version": "1.10.0",
3
+ "version": "1.11.1",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -35,11 +35,11 @@
35
35
  "typecheck": "tsc --noEmit"
36
36
  },
37
37
  "dependencies": {
38
- "@zerotal/core": "1.10.0"
38
+ "@zerotal/core": "1.11.1"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^5.8.0",
42
- "@zerotal/orm": "1.10.0"
42
+ "@zerotal/orm": "1.11.1"
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": [