@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 +82 -0
- package/docs/changelog.md +209 -0
- package/docs/commands.md +31 -0
- package/docs/inertia/rendering.md +67 -0
- package/docs/migrations.md +10 -0
- package/docs/notifications.md +41 -0
- package/docs/orm/index.md +15 -6
- package/docs/support-policy.md +14 -10
- package/docs/testing/http.md +52 -36
- package/docs/testing/index.md +41 -0
- package/docs/upgrade.md +81 -9
- package/package.json +3 -3
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:
|
package/docs/migrations.md
CHANGED
|
@@ -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/notifications.md
CHANGED
|
@@ -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
|
-
|
|
206
|
-
`
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
package/docs/support-policy.md
CHANGED
|
@@ -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
|
-
- **
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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,
|
|
76
|
-
|
|
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.
|
package/docs/testing/http.md
CHANGED
|
@@ -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
|
|
400
|
-
| ----------------------------------------------------------------------------------- |
|
|
401
|
-
| `assertStatus` | `assertStatus(expected: number): this`
|
|
402
|
-
| `assertOk` / `assertCreated` / `assertNoContent` | `(): this`
|
|
403
|
-
| `assertSuccessful` | `(): this`
|
|
404
|
-
| `assertMovedPermanently` | `(): this`
|
|
405
|
-
| `assertUnauthorized` / `assertForbidden` / `assertNotFound` / `assertUnprocessable` | `(): this`
|
|
406
|
-
| `assertServerError` | `(): this`
|
|
407
|
-
| `assertRedirect` | `assertRedirect(url: string): this`
|
|
408
|
-
| `
|
|
409
|
-
| `
|
|
410
|
-
| `
|
|
411
|
-
| `
|
|
412
|
-
| `
|
|
413
|
-
| `
|
|
414
|
-
| `
|
|
415
|
-
| `
|
|
416
|
-
| `
|
|
417
|
-
| `
|
|
418
|
-
| `
|
|
419
|
-
| `
|
|
420
|
-
| `
|
|
421
|
-
| `
|
|
422
|
-
| `
|
|
423
|
-
| `
|
|
424
|
-
| `
|
|
425
|
-
| `
|
|
426
|
-
| `
|
|
427
|
-
| `
|
|
428
|
-
| `
|
|
429
|
-
| `
|
|
430
|
-
| `
|
|
431
|
-
| `
|
|
432
|
-
| `
|
|
433
|
-
| `
|
|
434
|
-
| `
|
|
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
|
|
package/docs/testing/index.md
CHANGED
|
@@ -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
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
- **
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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.
|
|
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.
|
|
38
|
+
"@zerotal/core": "1.11.1"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"typescript": "^5.8.0",
|
|
42
|
-
"@zerotal/orm": "1.
|
|
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": [
|