@zerotal/arch 1.10.0 → 1.11.0

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,142 @@ 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.0 — 2026-08-30
31
+
32
+ Two production reports, from teams taking apps live on 1.9.0 — one shipping a
33
+ household-finance app to a VPS, one migrating a webmail platform from Flow to Inertia
34
+ and cutting it over to live traffic. Between them, nineteen findings.
35
+
36
+ The character of the list is the thing worth naming. Almost none of it is a crash.
37
+ Most of it fails silently or fails open: a release gate that always passes, a
38
+ `cascadeOnDelete` that deletes nothing, an `.env.example` carrying the key the project
39
+ actually runs with, a fake that agrees with whatever it is handed. Building an app
40
+ finds loud bugs quickly because somebody is watching. Deploying one finds the quiet
41
+ ones, months later, when nobody is.
42
+
43
+ **This is the first release under the versioning scheme in
44
+ [the upgrade guide](/docs/upgrade#versioning): a minor carries breaking changes, a
45
+ patch never does, and majors are annual.** So a `^1.10.0` range will pull this in.
46
+ Read the two items below before you take it.
47
+
48
+ ### Two things to do before upgrading
49
+
50
+ - **SQLite now enforces foreign keys.** Run `bun zt db:check-foreign-keys` first. It
51
+ lists any row whose parent is missing — legal before, a constraint violation now —
52
+ and exits non-zero, so a release script can gate on it.
53
+ - **If you have ever renamed a migration file**, `migrate` will now stop rather than
54
+ re-run it. That is the intended behaviour and the message says what to do; see
55
+ [the upgrade guide](/docs/upgrade#1-10-to-1-11).
56
+
57
+ ### Changed — BREAKING
58
+
59
+ - **SQLite enforces foreign keys.** `database.sqlite.foreignKeys` defaults to `true`.
60
+ SQLite ignores foreign keys unless the connection asks it not to, and it is the only
61
+ supported dialect that does — so `constrained()` and `cascadeOnDelete()` in a
62
+ migration described behaviour the database would not perform. Deleting a parent left
63
+ its children, silently, and every child had to be removed by hand in the right order
64
+ by application code that remembered to. An app's data-erasure path swept fifteen
65
+ tables and missed three, two of them holding uploaded files, so an account erasure
66
+ left the paperwork on disk. `zt db:check-foreign-keys` and `zt doctor` both report
67
+ the rows that enforcement would now reject; `sqlite: { foreignKeys: false }` takes
68
+ the old behaviour back while you fix them.
69
+
70
+ - **A renumbered migration is refused rather than re-run.** A migration is recorded
71
+ under its filename, so renaming one made an applied migration look pending — the
72
+ runner tried it again and failed on `table already exists`, a failed boot whose
73
+ error named a table rather than the rename. An app renumbered `001_` to `0001_` to
74
+ match this framework's own scaffold convention and would have made all nine of its
75
+ production migrations look unrun. `migrate` now recognises that shape, refuses, and
76
+ prints both spellings and the fix.
77
+
78
+ ### Fixed
79
+
80
+ - **`.env.example` no longer ships the key the project runs with.** Both files got the
81
+ same rendered content, so every scaffolded project committed a live, working
82
+ `APP_KEY` — `.gitignore` covers `.env` and not `.env.example`. And
83
+ `cp .env.example .env` is the first line of every deployment guide, so the published
84
+ key went on to sign production sessions. No strength check can catch it: as a string
85
+ the value is perfectly strong.
86
+
87
+ - **`.gitignore` covers the SQLite sidecars.** `*.sqlite` does not match
88
+ `db.sqlite-wal` or `db.sqlite-shm`, and WAL mode is on by default, so both exist in
89
+ every project and the write-ahead log holds rows not yet checkpointed. An app found
90
+ both in its first commit on a public host.
91
+
92
+ - **A command can fail without throwing.** `CommandRunner` ran `process.exit(0)` the
93
+ moment `run()` returned and never read `process.exitCode` — the idiomatic way to
94
+ fail a CLI without an exception. A release gate printed six blockers, set the code,
95
+ and exited `0`. `zt deploy` gates on the same value, so its own preflight had the
96
+ hole too: a gate that could not fail, failing open.
97
+
98
+ - **A Bun the project never asked for is a warning, not a refusal.**
99
+ `bun-plugin-tailwind` declares `bun` as a required peer, so `bun install` fetches a
100
+ second runtime and the guard refused to boot. An app took two outages on it. The
101
+ guard now asks whether the project _declared_ `bun`; if not, it warns and names both
102
+ the fix that works and the one that cannot.
103
+
104
+ - **SMTP submission and TLS verification.** STARTTLS on 587 completed its handshake
105
+ and sent nothing — a write issued before the handshake finishes is dropped. And
106
+ `rejectUnauthorized` is not enforced by the runtime on either transport, so TLS was
107
+ encrypted and would have accepted that encryption from anyone in the path.
108
+
109
+ - **Migration names no longer carry the platform that recorded them.** `Bun.Glob`
110
+ yields native separators, so on Windows the whole joined path went into the
111
+ `migrations` table. A database moved between platforms re-ran every migration.
112
+
113
+ - **React SSR emits the page's `<Head>` tags**, and `ctx.session.intended()` reads the
114
+ URL `AuthMiddleware` stored — the two APIs used different session keys, so an app
115
+ that mixed them was silently sent to `/` after every sign-in.
116
+
117
+ - **An empty string is an answer.** `required` treats `""` as absent, which is right
118
+ for a form and wrong for structured model output, where `""` is how a prompt asks a
119
+ model to say "this does not apply". A whole feature returned nothing because of it —
120
+ and shipped green, because `AiFake` never checked its canned object against the
121
+ schema. One half made the mistake; the other made it invisible.
122
+
123
+ - **`MonitorStore` no longer overwrites its own defaults with `undefined`**, and
124
+ `zt inertia:build` fails when it produces no files rather than serving a page with
125
+ no script.
126
+
127
+ ### Added
128
+
129
+ - **`zt db:check-foreign-keys`** — the rows enforcement would reject, by table and
130
+ rowid, exiting non-zero.
131
+ - **`Migration.id`** — a declared identity, so renaming a migration file is free.
132
+ - **`@zerotal/inertia/testing`'s `renderPage()`**, and a page-render test in the React
133
+ scaffold. An app shipped a blank page with 614 passing tests: every one asserted a
134
+ value or a status code, so a page could throw on its first paint and the suite
135
+ stayed green.
136
+ - **`AiError.transient`** — `true` for _this call failed_, `false` for _this machine
137
+ cannot do this_, so a service can latch itself off without classifying eleven error
138
+ classes by hand.
139
+ - **`assertRedirectContains()`**, and **`assertRedirect()` now compares paths
140
+ exactly** — it used `includes()`, so `assertRedirect("/login")` passed on
141
+ `/login-as-someone-else`.
142
+ - **`database.sqlite.foreignKeys`**, a doctor check for a `notifications` table that
143
+ is not the framework's, and a doctor check for a production `mail.driver` of `log`.
144
+
145
+ ### Changed
146
+
147
+ - **`config/session.ts` is scaffolded environment-aware**, so the first production
148
+ deploy no longer fails on the config validator's (correct) refusal.
149
+ - **Tailwind and its plugin move to `dependencies`** and the plugin is pinned — a
150
+ `--production` install that then builds on the server had neither.
151
+ - **The notification database channel is built on first use**, so an app that never
152
+ routes there never touches the table.
153
+ - **`@column({ type: "integer" })` compiles.** The object form took six type names
154
+ while the string form took twelve.
155
+ - **`--success` meets WCAG AA** at the contrast it is actually drawn at.
156
+
157
+ ### Documented
158
+
159
+ - [Persistent layouts](/docs/inertia/rendering#persistent-layouts), which failed only
160
+ in a browser and were documented nowhere;
161
+ [which Inertia redirects are covered](/docs/inertia/middleware#which-redirects-are-covered);
162
+ [pages render](/docs/testing#pages-render); the middleware names the framework
163
+ occupies; why `X-Forwarded-For` is counted from the right; and how to authenticate a
164
+ test when identity is not a row.
165
+
30
166
  ## 1.10.0 — 2026-08-30
31
167
 
32
168
  A second report from the team building on Zerotal, and the things it found. Most of this
@@ -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
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.0",
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.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^5.8.0",
42
- "@zerotal/orm": "1.10.0"
42
+ "@zerotal/orm": "1.11.0"
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": [