@zerotal/arch 1.9.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/api-surface.md +6 -6
- package/docs/ai.md +82 -0
- package/docs/changelog.md +292 -0
- package/docs/deployment.md +52 -4
- package/docs/helpers.md +51 -0
- package/docs/inertia/middleware.md +44 -0
- package/docs/inertia/rendering.md +67 -0
- package/docs/inertia/ssr.md +95 -10
- package/docs/middleware.md +31 -0
- package/docs/migrations.md +10 -0
- package/docs/orm/index.md +15 -6
- package/docs/rate-limiting.md +45 -17
- package/docs/scheduler.md +71 -8
- package/docs/support-policy.md +14 -10
- package/docs/testing/http.md +52 -36
- package/docs/testing/index.md +140 -0
- package/docs/upgrade.md +129 -9
- package/package.json +3 -3
- package/src/install/guidelines.ts +1 -1
- package/src/mcp/stdio.ts +3 -3
- package/src/tools/_probe.ts +2 -2
package/api-surface.md
CHANGED
|
@@ -119,7 +119,7 @@ interface DoctorReport = {
|
|
|
119
119
|
interface GuidelineOptions = {
|
|
120
120
|
packages: string[]
|
|
121
121
|
serverName: string
|
|
122
|
-
shape?: ProjectShape
|
|
122
|
+
shape?: ProjectShape | undefined
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
interface InstalledPackage = {
|
|
@@ -177,8 +177,8 @@ interface SchemaReport = {
|
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
interface SpawnProbeOptions = {
|
|
180
|
-
cwd?: string
|
|
181
|
-
timeoutMs?: number
|
|
180
|
+
cwd?: string | undefined
|
|
181
|
+
timeoutMs?: number | undefined
|
|
182
182
|
}
|
|
183
183
|
|
|
184
184
|
interface ToolContext = {
|
|
@@ -262,10 +262,10 @@ interface ServerIdentity = {
|
|
|
262
262
|
}
|
|
263
263
|
|
|
264
264
|
interface StdioOptions = {
|
|
265
|
-
input?: ReadableStream<Uint8Array<ArrayBufferLike>>
|
|
266
|
-
log?: (message: string) => void
|
|
265
|
+
input?: ReadableStream<Uint8Array<ArrayBufferLike>> | undefined
|
|
266
|
+
log?: ((message: string) => void) | undefined
|
|
267
267
|
server: McpServer
|
|
268
|
-
write?: (frame: string) => void
|
|
268
|
+
write?: ((frame: string) => void) | undefined
|
|
269
269
|
}
|
|
270
270
|
|
|
271
271
|
interface ToolOutcome = {
|
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,298 @@ 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
|
+
|
|
166
|
+
## 1.10.0 — 2026-08-30
|
|
167
|
+
|
|
168
|
+
A second report from the team building on Zerotal, and the things it found. Most of this
|
|
169
|
+
release is failures that were silent by construction — mail delivered nowhere, a page
|
|
170
|
+
shared as a grey rectangle, a schedule that never fired, a rate limiter with one bucket
|
|
171
|
+
for everybody. None of them logged anything.
|
|
172
|
+
|
|
173
|
+
**Three things to know before upgrading.**
|
|
174
|
+
|
|
175
|
+
- **React apps using SSR now need `@inertiajs/react` installed.** It is the same adapter
|
|
176
|
+
your browser entry point already uses; the server renders through its `<App>` so
|
|
177
|
+
`<Head>` works. A missing one is a named error rather than a silent omission.
|
|
178
|
+
- **`scheduler.timezone` does something now.** It was documented as informational and read
|
|
179
|
+
by nothing. Its default moved from the literal `"UTC"` to **the system zone**, so an app
|
|
180
|
+
that never set the key keeps doing exactly what it did — but an app that set it now gets
|
|
181
|
+
what it asked for. If you set it to `"UTC"` on a server that is not on UTC, your
|
|
182
|
+
schedules will move. See [the upgrade guide](/docs/upgrade#1-9-to-1-10).
|
|
183
|
+
- **Named rate limiters need `.trustedProxies(n)` behind a proxy.** `.byIp()`, `.byUser()`
|
|
184
|
+
and `.byApiKey()` now ignore `X-Forwarded-For` unless told how many proxies sit in
|
|
185
|
+
front, which is the same rule `ThrottleMiddleware` already followed. `zt doctor` reports
|
|
186
|
+
any that need it.
|
|
187
|
+
|
|
188
|
+
### Added
|
|
189
|
+
|
|
190
|
+
- **`zt assets:prune`** — removes the chunks an earlier release left behind, on the machine
|
|
191
|
+
that never ran a build. `assets:build --clean` cleans the directory it _builds into_,
|
|
192
|
+
which does nothing for the usual release shape: build here, tar the output, extract it
|
|
193
|
+
over `public/` there. Extracting merges, so every deploy adds another set of
|
|
194
|
+
content-hashed chunks and none are ever removed. One app reached 225 chunk files for the
|
|
195
|
+
49 its entry point references. Ship `.zerotal/` with the release and this removes what
|
|
196
|
+
the build record does not claim. See [Deployment](/docs/deployment#assetsprune--clearing-up-after-the-extract-instead-of-before-it).
|
|
197
|
+
|
|
198
|
+
- **`zt deploy:<env> --check`** — the preflight gate on its own, for the point in a release
|
|
199
|
+
script where the new code is on disk and the service has not restarted. Exit 0 and
|
|
200
|
+
restart; exit non-zero and keep serving the previous release. Everything that can refuse
|
|
201
|
+
already runs by the end of preflight and none of it mutates, so stopping there is a
|
|
202
|
+
complete answer rather than half a deploy.
|
|
203
|
+
|
|
204
|
+
- **`RateLimiter.trustedProxies(n)`** on the fluent builder, and
|
|
205
|
+
**`res.assertInertiaRedirect(url)`** in `@zerotal/testing` — the assertion that checks
|
|
206
|
+
what actually breaks on an Inertia redirect, which is the `X-Inertia` marker rather than
|
|
207
|
+
the status and `Location` a normal redirect assertion already covers.
|
|
208
|
+
|
|
209
|
+
- **`@zerotal/core/runtime`** (`zerotal/runtime`) — the runtime checks as exports, so a
|
|
210
|
+
script or a test can make the same assertion `zt` makes: `runtimeBelowFloor`,
|
|
211
|
+
`declaredBunFloor`, `runtimeMismatch`, `bunBinary` and the messages that go with them.
|
|
212
|
+
|
|
213
|
+
- **`definedOnly()` and `Resolved<T>`** on `@zerotal/core/helpers`, for merging an options
|
|
214
|
+
bag over defaults without an explicit `undefined` overwriting one.
|
|
215
|
+
|
|
216
|
+
- **Scheduler timezone helpers** — `wallClockIn`, `isValidTimeZone`,
|
|
217
|
+
`CronExpression.matchesIn` and `CronExpression.nextRunAfterIn`, plus `SchedulerError` and
|
|
218
|
+
`UnknownTimeZoneError`.
|
|
219
|
+
|
|
220
|
+
- **A boot line when a convention is skipped in this environment.** An env-restricted
|
|
221
|
+
concern is skipped by _not looking_, which is correct and completely silent: an app ran
|
|
222
|
+
for weeks in production with `app/schedules` full and no worker process, and nothing
|
|
223
|
+
logged anything because from a web process's point of view nothing existed.
|
|
224
|
+
|
|
225
|
+
### Changed
|
|
226
|
+
|
|
227
|
+
- **Optional properties in public option shapes are declared `?: T | undefined`.** The
|
|
228
|
+
generated `tsconfig.json` enables `exactOptionalPropertyTypes`, under which
|
|
229
|
+
`image?: string` refuses a key that is present and holds `undefined` — so
|
|
230
|
+
`{ image: candidate ?? undefined }`, the most ordinary thing there is, did not compile
|
|
231
|
+
and every conditionally-absent field had to be spelled `...(x ? { x } : {})`. 438
|
|
232
|
+
properties across 115 files. Nothing changes for a reader: an absent optional property
|
|
233
|
+
already read as `undefined`.
|
|
234
|
+
|
|
235
|
+
- **`scheduler.timezone` is honoured**, and its default is the system zone rather than the
|
|
236
|
+
literal `"UTC"`. See the note above.
|
|
237
|
+
|
|
238
|
+
- **`mail.driver: "log"` fails `zt doctor` in production** when `mail.from.address` has been
|
|
239
|
+
configured, and warns when it is still the placeholder. Mail written to a log file is
|
|
240
|
+
delivered to nobody and says so nowhere.
|
|
241
|
+
|
|
242
|
+
### Fixed
|
|
243
|
+
|
|
244
|
+
- **React SSR emitted no `<Head>` tags at all.** The React branch rendered the page
|
|
245
|
+
component directly, and `<Head>` renders nothing — it reports its children to a head
|
|
246
|
+
manager it reads from context, and rendering the component alone puts none there. So
|
|
247
|
+
every page served the template's `<head>`: no title, no description, no card. Nothing
|
|
248
|
+
failed and nothing logged, because the page is perfect in a browser, where React has run.
|
|
249
|
+
Only the readers that do not run JavaScript saw it — which is every link-preview scraper
|
|
250
|
+
and every `curl`.
|
|
251
|
+
|
|
252
|
+
- **SMTP submission on port 587 sent nothing.** The STARTTLS handshake completed and then
|
|
253
|
+
the client's `EHLO` was dropped: `upgradeTLS()` returns the new socket while the
|
|
254
|
+
handshake is still in flight, and a write issued in that window is lost — not buffered,
|
|
255
|
+
not an error, gone. Port 465 was unaffected, so mail worked on the port nobody documents
|
|
256
|
+
and the 587 every provider _does_ document produced silence: no error, no bounce, no log
|
|
257
|
+
line, and password resets that never arrived.
|
|
258
|
+
|
|
259
|
+
- **TLS certificates were not actually verified, on either SMTP transport.**
|
|
260
|
+
`rejectUnauthorized` is not enforced by the runtime — it reports a self-signed
|
|
261
|
+
certificate as authorized and puts the real reason beside it — so the connection was
|
|
262
|
+
encrypted and would have accepted that encryption from anyone in the network path. The
|
|
263
|
+
driver reads the handshake result itself now and fails closed.
|
|
264
|
+
|
|
265
|
+
- **A scheduled task with a `timezone` took the whole scheduler down.** `Bun.cron`'s options
|
|
266
|
+
form throws, and it throws during registration, so the worker died on boot and
|
|
267
|
+
restart-looped: one task with a timezone stopped every task in the app. Zerotal evaluates
|
|
268
|
+
the zone itself now, and a task that cannot register takes only itself out.
|
|
269
|
+
|
|
270
|
+
- **Named rate limiters ignored `trustedProxies`, and `zt doctor` was told not to look.**
|
|
271
|
+
`.byIp()`, `.byUser()` and `.byApiKey()` used a resolver that read the socket address and
|
|
272
|
+
fell back to the leftmost `X-Forwarded-For` entry with no proxy count. Behind a reverse
|
|
273
|
+
proxy every visitor keyed on the proxy's own address and shared one bucket, so a `login`
|
|
274
|
+
limiter of five attempts a minute was five attempts a minute for the entire user base and
|
|
275
|
+
one attacker locked everybody out. The doctor check written to catch this exempted any
|
|
276
|
+
custom `keyResolver`, which is what all three are.
|
|
277
|
+
|
|
278
|
+
- **`ctx.session.intended()` could not read what `AuthMiddleware` stored.** It used the key
|
|
279
|
+
`intended` while the middleware and `redirect().intended()` used `intended_url`. Each pair
|
|
280
|
+
was internally consistent and separately tested, so every test passed — and an app that
|
|
281
|
+
mixed them, which the documentation invited, was silently sent to `/` after every sign-in.
|
|
282
|
+
|
|
283
|
+
- **`MonitorStore` overwrote its own defaults with `undefined`.** It applied `?? …` defaults
|
|
284
|
+
and then spread `...opts` after them, and spread copies own properties even when they
|
|
285
|
+
hold `undefined` — so an unset config put `undefined` back over the retention window and
|
|
286
|
+
`prune()` computed a `NaN` cutoff, pruning nothing and reporting nothing.
|
|
287
|
+
|
|
288
|
+
- **`engines.bun` is enforced.** Every generated app writes a floor and nothing read it.
|
|
289
|
+
`Intl` output moves between Bun releases, so a suite with currency or date assertions goes
|
|
290
|
+
red on a runtime that is otherwise fine and the failures name the code they touch rather
|
|
291
|
+
than the binary.
|
|
292
|
+
|
|
293
|
+
- **The asset build record is portable.** Its filename was hashed from the output
|
|
294
|
+
directory's _absolute_ path, so a record shipped with a release matched nothing at the
|
|
295
|
+
other end and moving a checkout silently orphaned it.
|
|
296
|
+
|
|
297
|
+
- **The React SSR root is marked `data-server-rendered`**, so the client hydrates the markup
|
|
298
|
+
instead of discarding it and rendering the page a second time. `POST /__ssr` returns the
|
|
299
|
+
same body shape as the Vue branch.
|
|
300
|
+
|
|
301
|
+
### Documented
|
|
302
|
+
|
|
303
|
+
- **["What a crawler sees"](/docs/inertia/ssr#what-a-crawler-sees)** — `inertia()` does not
|
|
304
|
+
server-render the component, which is the normal Inertia arrangement and worth saying out
|
|
305
|
+
loud: the served document is a `<title>` and a JSON blob. Which readers run JavaScript,
|
|
306
|
+
which do not, and the three ways to give the second group something to read.
|
|
307
|
+
|
|
308
|
+
- **[Which Inertia redirects are covered](/docs/inertia/middleware#which-redirects-are-covered)**
|
|
309
|
+
— all of them, because `useOnce()` registers the middleware globally. Written down because
|
|
310
|
+
the opposite belief is what keeps an app's own workaround on every request forever.
|
|
311
|
+
|
|
312
|
+
- **[`bun test` vs `bun zt test`](/docs/testing#bun-test-vs-bun-zt-test)** — the 30-second
|
|
313
|
+
timeout (the `bunfig.toml` key is ignored by Bun and `setDefaultTimeout()` in a preload
|
|
314
|
+
reaches only the first file, so the flag is the only mechanism that works), the runtime
|
|
315
|
+
check, and the fact that configuration resolves once per process — so a test that mutates
|
|
316
|
+
the environment in `beforeAll` is testing whichever file booted first.
|
|
317
|
+
|
|
318
|
+
- **[Timezones](/docs/scheduler#timezones)** in the scheduler, **[the middleware names the
|
|
319
|
+
framework occupies](/docs/middleware#names-the-framework-already-occupies)**, and why
|
|
320
|
+
`X-Forwarded-For` is [counted from the right](/docs/rate-limiting#trustedproxies).
|
|
321
|
+
|
|
30
322
|
## 1.9.0 — 2026-08-29
|
|
31
323
|
|
|
32
324
|
The gaps an app was filling in for itself: one Bun per project, a database backup that is not
|
package/docs/deployment.md
CHANGED
|
@@ -38,6 +38,7 @@ deliberate: systemd, your container runtime or your deploy script owns process
|
|
|
38
38
|
lifecycle, and this gives it a gate to restart behind.
|
|
39
39
|
|
|
40
40
|
```bash
|
|
41
|
+
bun zt deploy:production --check # run the gate only — see below
|
|
41
42
|
bun zt deploy:production --dry-run # print the plan, run none of it
|
|
42
43
|
bun zt deploy:production --skip-migrations # release without touching the schema
|
|
43
44
|
bun zt deploy:production --probe=https://example.com # real handshake at the end
|
|
@@ -75,10 +76,11 @@ Omit the file entirely and you get `DEFAULT_DEPLOY_TARGETS`: `production` and
|
|
|
75
76
|
### Your own release gate
|
|
76
77
|
|
|
77
78
|
The framework's preflight knows the things a framework can know: that this really is the
|
|
78
|
-
environment you think it is, that the config validators pass, that `doctor` is happy
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
environment you think it is, that the config validators pass, that `doctor` is happy —
|
|
80
|
+
which now includes refusing a production release whose `mail.driver` is still `log`,
|
|
81
|
+
because mail written to a log file is delivered to nobody and says so nowhere. It
|
|
82
|
+
cannot know that this workspace has no cancellation policy, or that the owner account
|
|
83
|
+
is still on the password `admin:create` issued it. Those refusals are yours.
|
|
82
84
|
|
|
83
85
|
Write them as a command and name it `release:check` (exported as
|
|
84
86
|
`CONVENTIONAL_PREFLIGHT_COMMAND`). The pipeline finds it by name — nothing to wire up:
|
|
@@ -124,6 +126,27 @@ Two things worth adding while you are there: `assets:build` and `inertia:build`
|
|
|
124
126
|
`--clean`, which removes anything in the output directory the build did not write — see
|
|
125
127
|
[Build assets](#build-assets).
|
|
126
128
|
|
|
129
|
+
### `--check`: the gate on its own
|
|
130
|
+
|
|
131
|
+
A release script has a moment where the new code is on disk and the service has not
|
|
132
|
+
restarted yet. That is the moment to ask whether this release is fit to go live, and
|
|
133
|
+
`--check` is the whole preflight and nothing else:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
# on the box, after the new release is unpacked and before the restart
|
|
137
|
+
APP_ENV=production bun zt deploy:production --check || exit 1
|
|
138
|
+
systemctl restart app
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
It runs the environment check, the config validators with production semantics,
|
|
142
|
+
`doctor`, and your own `release:check` — everything that can refuse — and builds
|
|
143
|
+
nothing, migrates nothing, restarts nothing. Exit 0 and restart; exit non-zero and
|
|
144
|
+
keep serving the previous release, which is the point. A workspace that has lost its
|
|
145
|
+
banking details, or had its mail driver knocked back to `log`, never goes live broken.
|
|
146
|
+
|
|
147
|
+
`--check` and `--dry-run` answer different questions. `--dry-run` prints the plan
|
|
148
|
+
without running any of it, including the gate. `--check` runs the gate for real.
|
|
149
|
+
|
|
127
150
|
> **Note** — `deploy:<env>` runs **where the app runs**, with that environment's
|
|
128
151
|
> variables. It does not reach another machine over SSH. Run it on the box, or in
|
|
129
152
|
> the container build, as the step before the restart.
|
|
@@ -310,6 +333,31 @@ Ordering matters if the old release is still serving traffic: clearing the direc
|
|
|
310
333
|
its bundles away, so do it as close to the swap as you can, or stage the release in a new
|
|
311
334
|
directory and move it into place.
|
|
312
335
|
|
|
336
|
+
### `assets:prune` — clearing up after the extract instead of before it
|
|
337
|
+
|
|
338
|
+
Clearing the directory first has a cost the ordering note above hints at: between the
|
|
339
|
+
`rm -rf` and the new files landing, the release still serving traffic has no bundles.
|
|
340
|
+
If you would rather extract over the top and tidy afterwards, `assets:build` records
|
|
341
|
+
what it wrote and `assets:prune` removes what that record does not claim:
|
|
342
|
+
|
|
343
|
+
```bash
|
|
344
|
+
# on the build machine — the record is written under .zerotal/
|
|
345
|
+
bun zt assets:build
|
|
346
|
+
tar -czf release.tgz public .zerotal # ship the record with the release
|
|
347
|
+
|
|
348
|
+
# on the server, after extracting and before the restart
|
|
349
|
+
bun zt assets:prune --dry-run # list what would go
|
|
350
|
+
bun zt assets:prune
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
**Ship `.zerotal/` with the release** — that is the part that makes it work. Without
|
|
354
|
+
the record, "which files belong to this release" has no answer on a server that never
|
|
355
|
+
ran a build, and `assets:prune` says so and removes nothing rather than guessing.
|
|
356
|
+
|
|
357
|
+
It is conservative by design: it deletes a file only when the record does not claim it
|
|
358
|
+
_and_ it is named the way the bundler names a code-split chunk. Your images and
|
|
359
|
+
favicon are never candidates, because an output directory is usually `public/`.
|
|
360
|
+
|
|
313
361
|
### `--clean` for a directory the build does not own outright
|
|
314
362
|
|
|
315
363
|
The cleanup above recognises the filenames `Bun.build()` produces. An app that sets its own
|
package/docs/helpers.md
CHANGED
|
@@ -393,6 +393,57 @@ matches how you want overrides to behave.
|
|
|
393
393
|
> configured `driver`) are replaced by reference — they keep their prototype and are
|
|
394
394
|
> never merged into.
|
|
395
395
|
|
|
396
|
+
### `definedOnly` and `Resolved<T>` — merging a shallow options bag
|
|
397
|
+
|
|
398
|
+
Every public option shape in the framework declares its optional properties as
|
|
399
|
+
`?: T | undefined`, so that the most ordinary thing there is compiles under the
|
|
400
|
+
`exactOptionalPropertyTypes` the generated `tsconfig.json` turns on:
|
|
401
|
+
|
|
402
|
+
```typescript
|
|
403
|
+
// in a controller
|
|
404
|
+
import { Media } from "zerotal/media";
|
|
405
|
+
|
|
406
|
+
await Media.store(file, { collection: request.input("collection") ?? undefined });
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
That flexibility moves a problem to the merge. **Object spread copies own properties
|
|
410
|
+
even when their value is `undefined`**, so `{ ...DEFAULTS, ...options }` lets an
|
|
411
|
+
explicitly-`undefined` field overwrite a default rather than leave it standing —
|
|
412
|
+
which is how a `pingInterval` of `undefined` once became `setInterval(fn, 0)` and
|
|
413
|
+
~830 pings a second. `definedOnly` is the fix: it drops the keys whose value is
|
|
414
|
+
`undefined`, so "supplied as undefined" reads as "not supplied".
|
|
415
|
+
|
|
416
|
+
```typescript fragment
|
|
417
|
+
// in a class that takes options
|
|
418
|
+
const merged = { ...DEFAULTS, ...definedOnly(options) };
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
`deepMerge` already does this for you — it skips `undefined` overrides at every
|
|
422
|
+
depth — so `definedOnly` is for the shallow case where a spread is all you need.
|
|
423
|
+
|
|
424
|
+
`Resolved<T>` is the type of what comes out. **`Required<T>` is not the same thing**,
|
|
425
|
+
and that is the trap: `-?` removes the optionality a `?` introduced, but it does not
|
|
426
|
+
remove an `undefined` written into the type. So `Required<MediaOptions>` still hands
|
|
427
|
+
back `string | undefined` for a `collection?: string | undefined`, and a "defaults
|
|
428
|
+
have been applied" type quietly stops meaning that.
|
|
429
|
+
|
|
430
|
+
```typescript fragment
|
|
431
|
+
// in a class that takes options
|
|
432
|
+
interface Options {
|
|
433
|
+
retentionDays?: number | undefined;
|
|
434
|
+
storage?: string | undefined;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Required<Pick<Options, "retentionDays">> → { retentionDays: number | undefined } ✗
|
|
438
|
+
// Resolved<Pick<Options, "retentionDays">> → { retentionDays: number } ✓
|
|
439
|
+
private readonly _opts: Resolved<Pick<Options, "retentionDays">> &
|
|
440
|
+
Omit<Options, "retentionDays">;
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
`Omit` rather than `& Options` for the rest: intersecting with the whole shape puts
|
|
444
|
+
the optional declaration of each resolved field back alongside the required one, so
|
|
445
|
+
the field reads as possibly `undefined` in the very code that just gave it a default.
|
|
446
|
+
|
|
396
447
|
## Fluent wrappers
|
|
397
448
|
|
|
398
449
|
### fluent
|
|
@@ -50,6 +50,50 @@ would just register it twice (and `useOnce` guards against that anyway).
|
|
|
50
50
|
> the authenticated user is always populated by the time props are built; you don't
|
|
51
51
|
> need to hand-order `InertiaMiddleware` relative to auth.
|
|
52
52
|
|
|
53
|
+
## Which redirects are covered
|
|
54
|
+
|
|
55
|
+
**All of them.** `useOnce()` registers `InertiaMiddleware` as _global_ middleware, so it
|
|
56
|
+
runs on every request the app serves — however that route declared its own middleware,
|
|
57
|
+
whether as an array, a map form (`{ ALL, POST }`), a group, or nothing at all. There is
|
|
58
|
+
no route that reaches a controller without passing through it, so there is no redirect
|
|
59
|
+
it does not mark.
|
|
60
|
+
|
|
61
|
+
This is worth stating plainly because the opposite belief is expensive. An app that
|
|
62
|
+
thinks some routes miss the middleware writes its own global `InertiaRedirectMiddleware`
|
|
63
|
+
to cover them, and then cannot tell whether it is still needed: removing it leaves every
|
|
64
|
+
test green either way, because the tests assert a status and a `Location` and those were
|
|
65
|
+
never the part that broke.
|
|
66
|
+
|
|
67
|
+
Three things have to be true for the Inertia client to follow a redirect, and the
|
|
68
|
+
middleware guarantees all three:
|
|
69
|
+
|
|
70
|
+
| | Set on |
|
|
71
|
+
| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
|
72
|
+
| A redirect status — `303` after a non-GET, so the browser follows with `GET` | every Inertia redirect |
|
|
73
|
+
| `Location` | your handler; carried through untouched, along with `Set-Cookie` |
|
|
74
|
+
| `X-Inertia: true` | **every** Inertia redirect, including one your handler already returned as a `303` |
|
|
75
|
+
|
|
76
|
+
That last row is the one that was wrong before 1.8.0: the marker was set inside the
|
|
77
|
+
302→303 conversion, so a handler doing the protocol-correct thing already —
|
|
78
|
+
`http.redirect(to, 303)` — skipped the only line that marked the response as Inertia's.
|
|
79
|
+
The request succeeded, the row was written, and the form sat there with its fields still
|
|
80
|
+
filled in.
|
|
81
|
+
|
|
82
|
+
### Pinning it from a test
|
|
83
|
+
|
|
84
|
+
`assertRedirect` checks the two headers that were never the problem. `assertInertiaRedirect`
|
|
85
|
+
checks all three:
|
|
86
|
+
|
|
87
|
+
```typescript fragment
|
|
88
|
+
// tests/Feature/OrdersTest.ts
|
|
89
|
+
const res = await app.post("/orders", data, { headers: { "X-Inertia": "true" } });
|
|
90
|
+
|
|
91
|
+
res.assertInertiaRedirect("/orders/1");
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Send the request with the `X-Inertia` header, or there is nothing to assert — a redirect
|
|
95
|
+
to a browser that is not running Inertia is just a redirect.
|
|
96
|
+
|
|
53
97
|
## Asset versioning
|
|
54
98
|
|
|
55
99
|
The asset version is a string sent as part of every page object. When it changes, the
|