@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 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
@@ -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. It
79
- cannot know that this workspace has no cancellation policy, that mail is still wired to the
80
- `log` driver so nothing is ever sent, or that the owner account is still on the password
81
- `admin:create` issued it. Those refusals are yours.
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