@zerotal/arch 1.8.1 → 1.10.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/admin/actions.md +15 -0
- package/docs/admin/auth.md +10 -0
- package/docs/admin/dashboard.md +12 -0
- package/docs/admin/extending-ui.md +14 -0
- package/docs/admin/forms.md +15 -0
- package/docs/admin/operations.md +12 -0
- package/docs/admin/resources.md +6 -0
- package/docs/admin/tables.md +21 -0
- package/docs/audit.md +5 -0
- package/docs/authentication.md +110 -1
- package/docs/broadcasting/references.md +17 -0
- package/docs/cache.md +5 -0
- package/docs/carbon.md +5 -0
- package/docs/changelog.md +281 -0
- package/docs/client/index.md +17 -0
- package/docs/commands.md +6 -0
- package/docs/components.md +73 -0
- package/docs/config-system.md +54 -0
- package/docs/cookies.md +6 -0
- package/docs/deployment.md +151 -13
- package/docs/devtools.md +5 -0
- package/docs/email-verification.md +26 -1
- package/docs/encryption.md +21 -0
- package/docs/errors.md +2 -0
- package/docs/flow/components.md +54 -0
- package/docs/flow/forms.md +57 -0
- package/docs/flow/references.md +14 -0
- package/docs/getting-started.md +38 -0
- package/docs/health.md +19 -0
- package/docs/helpers.md +150 -0
- package/docs/i18n.md +5 -0
- package/docs/inertia/middleware.md +44 -0
- package/docs/inertia/props.md +70 -0
- package/docs/inertia/ssr.md +95 -10
- package/docs/lock.md +15 -0
- package/docs/logger.md +38 -0
- package/docs/middleware.md +31 -0
- package/docs/migrations.md +47 -0
- package/docs/monitor.md +59 -0
- package/docs/notifications.md +11 -0
- package/docs/orm/casts.md +6 -0
- package/docs/orm/lifecycle.md +18 -0
- package/docs/orm/queries.md +10 -0
- package/docs/orm/relationships.md +30 -0
- package/docs/queue.md +10 -0
- package/docs/rate-limiting.md +84 -21
- package/docs/responses.md +23 -0
- package/docs/routing.md +16 -0
- package/docs/scheduler.md +82 -8
- package/docs/session.md +6 -0
- package/docs/social.md +10 -0
- package/docs/storage.md +21 -0
- package/docs/support-policy.md +13 -1
- package/docs/telemetry.md +8 -0
- package/docs/tenancy.md +6 -0
- package/docs/testing/index.md +105 -0
- package/docs/upgrade.md +48 -0
- package/docs/validator.md +9 -0
- package/docs/view.md +6 -0
- 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/docs/helpers.md
CHANGED
|
@@ -214,6 +214,105 @@ singularize("people"); // 'person'
|
|
|
214
214
|
tableNameFor("BlogPost"); // 'blog_posts'
|
|
215
215
|
```
|
|
216
216
|
|
|
217
|
+
## Sharing helpers with the browser — `zerotal/shared`
|
|
218
|
+
|
|
219
|
+
Importing `zerotal` into a client bundle drags the server in behind it. So the helpers that
|
|
220
|
+
have no server in them are also published on their own entry point:
|
|
221
|
+
|
|
222
|
+
```tsx
|
|
223
|
+
// resources/js/pages/Trips/Index.tsx — a browser bundle
|
|
224
|
+
import { pluralize, formatMoney, Str } from "zerotal/shared";
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Everything reachable from `zerotal/shared` is pure — no `node:` imports, no `Bun` globals, no
|
|
228
|
+
config, no container, no request context. Importing it pulls in these functions and nothing
|
|
229
|
+
else, and a test in the framework's own suite bundles the entry point for the browser to keep
|
|
230
|
+
that true.
|
|
231
|
+
|
|
232
|
+
It carries `pluralize`, `singularize`, `snakeCase`, `camelCase`, `tableNameFor`, the whole
|
|
233
|
+
`Str` namespace, and the formatters below.
|
|
234
|
+
|
|
235
|
+
**Why it matters more than convenience.** Without it, a page that needs `pluralize` gets a
|
|
236
|
+
second implementation written by hand — and the second copy is always the worse one, because
|
|
237
|
+
the irregulars and the inflect-only-the-last-word rule are exactly what someone re-deriving
|
|
238
|
+
it leaves out. `pluralize("supplier line")` is `"supplier lines"`; the naive rule gives
|
|
239
|
+
`"suppliers line"`. One import removes the divergence rather than managing it.
|
|
240
|
+
|
|
241
|
+
### Formatting both sides can agree on
|
|
242
|
+
|
|
243
|
+
`Intl` is in both runtimes and does the work; the risk is that a server helper and a browser
|
|
244
|
+
helper make the same decision twice. A total that reads `R 39 147` on screen and `R39,147.00`
|
|
245
|
+
on the invoice looks like two different numbers to the person paying it.
|
|
246
|
+
|
|
247
|
+
```typescript
|
|
248
|
+
import { formatMoney, formatNumber, formatDate } from "zerotal/shared";
|
|
249
|
+
|
|
250
|
+
// Minor units by default — how a column that must not lose a cent stores it.
|
|
251
|
+
formatMoney(3_914_700, { currency: "ZAR", locale: "en-ZA" });
|
|
252
|
+
formatMoney(39_147, { currency: "USD", locale: "en-US", minorUnits: false });
|
|
253
|
+
|
|
254
|
+
formatNumber(39147.5, { locale: "en-GB", maximumFractionDigits: 1 }); // '39,147.5'
|
|
255
|
+
|
|
256
|
+
formatDate("2026-08-28T21:30:00Z", { locale: "en-ZA", timeZone: "Africa/Johannesburg" });
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Pass `locale` explicitly wherever the two sides must match. Left off, it is the machine's on
|
|
260
|
+
the server and the reader's in the browser, and those are not the same. Pass `timeZone` for
|
|
261
|
+
the same reason: a machine on UTC and a reader in Cape Town disagree about which _day_ an
|
|
262
|
+
11pm booking happened on, and that is the shape the bug takes.
|
|
263
|
+
|
|
264
|
+
Each formatter takes its own options interface, all extending `FormatOptions` (which carries
|
|
265
|
+
`locale`):
|
|
266
|
+
|
|
267
|
+
| Interface | Used by | Adds |
|
|
268
|
+
| --------------- | -------------- | ---------------------------------------------------------------------- |
|
|
269
|
+
| `FormatOptions` | — (the base) | `locale` |
|
|
270
|
+
| `MoneyOptions` | `formatMoney` | `currency` (required), `minorUnits` (default `true`), `fractionDigits` |
|
|
271
|
+
| `NumberOptions` | `formatNumber` | `minimumFractionDigits`, `maximumFractionDigits` |
|
|
272
|
+
| `DateOptions` | `formatDate` | `dateStyle`, `timeStyle`, `timeZone` |
|
|
273
|
+
|
|
274
|
+
`formatDate` returns an empty string for an unparseable value rather than `Invalid Date`, so
|
|
275
|
+
a bad timestamp renders as a blank rather than as words in the middle of a page.
|
|
276
|
+
|
|
277
|
+
## Outbound HTTP — Http
|
|
278
|
+
|
|
279
|
+
`Http` is the fluent client for calling another service. It is `fetch` with the things you would
|
|
280
|
+
otherwise write around every call — auth, timeout, retry, JSON — already there, and one place to
|
|
281
|
+
intercept in a test:
|
|
282
|
+
|
|
283
|
+
```typescript fragment
|
|
284
|
+
import { Http } from "zerotal/http";
|
|
285
|
+
|
|
286
|
+
const response = await Http.withToken(apiKey).timeout(5_000).retry(3).post("/charges", { amount });
|
|
287
|
+
|
|
288
|
+
if (response.ok) {
|
|
289
|
+
const charge = await response.json<{ id: string }>();
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
Every verb — `get`, `post`, `put`, `patch`, `delete`, `head` — returns a `PendingRequest`, which
|
|
294
|
+
is awaitable _and_ chainable: `withHeaders`, `withToken`, `withBasicAuth`, `acceptJson`,
|
|
295
|
+
`timeout`, `retry`, `withJson`, `withFormData`. Awaiting it gives an `HttpClientResponse` with
|
|
296
|
+
`status`, `ok`, `headers`, `json()`, `text()` and `blob()`.
|
|
297
|
+
|
|
298
|
+
**A failed response is not a thrown error by default.** A 404 is an answer, and an integration
|
|
299
|
+
that treats every non-2xx as an exception cannot tell "no such customer" from "the service is
|
|
300
|
+
down". Call `.throw()` on the response when you do want the non-2xx to raise — it throws
|
|
301
|
+
`HttpClientError`, which carries the response so a handler can still read the status.
|
|
302
|
+
|
|
303
|
+
```typescript fragment
|
|
304
|
+
// Let a 404 be a value, and anything else be a problem.
|
|
305
|
+
const response = await Http.get(`/customers/${id}`);
|
|
306
|
+
if (response.status === 404) return null;
|
|
307
|
+
return response.throw().json<Customer>();
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
In tests, `Http.fake()` intercepts all of it — see [Mocking](/docs/testing/mocking#outbound-http).
|
|
311
|
+
|
|
312
|
+
`Http` is a class rather than a facade, so a request is made with the same import everywhere.
|
|
313
|
+
`QueryInput` is what a query object may hold, and `PaginatedData` the shape of a paginated body
|
|
314
|
+
when the other end returns one.
|
|
315
|
+
|
|
217
316
|
## Objects — deepMerge
|
|
218
317
|
|
|
219
318
|
Recursively merge an override object onto a base, lodash-style: nested plain
|
|
@@ -294,6 +393,57 @@ matches how you want overrides to behave.
|
|
|
294
393
|
> configured `driver`) are replaced by reference — they keep their prototype and are
|
|
295
394
|
> never merged into.
|
|
296
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
|
+
|
|
297
447
|
## Fluent wrappers
|
|
298
448
|
|
|
299
449
|
### fluent
|
package/docs/i18n.md
CHANGED
|
@@ -509,6 +509,11 @@ res.assertSee("Votre panier est vide");
|
|
|
509
509
|
|
|
510
510
|
See [Configuration](#configuration) for the `config/i18n.ts` fields.
|
|
511
511
|
|
|
512
|
+
## Types
|
|
513
|
+
|
|
514
|
+
`LocaleResolver` decides the locale for a request — the header, a route param, or the signed-in
|
|
515
|
+
user's preference. `TranslatorOptions` configures the translator itself.
|
|
516
|
+
|
|
512
517
|
## Next steps
|
|
513
518
|
|
|
514
519
|
- [Validator](/docs/validator) — pair form validation with translated messages.
|
|
@@ -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
|
package/docs/inertia/props.md
CHANGED
|
@@ -25,6 +25,70 @@ import { Inertia } from "@zerotal/inertia";
|
|
|
25
25
|
> `defer()` loads after first paint; `merge()`/`scroll()` combine new data with what
|
|
26
26
|
> the client already has.
|
|
27
27
|
|
|
28
|
+
## Page props are page source
|
|
29
|
+
|
|
30
|
+
**A page receives a projection chosen by its route, never a model — unless that model has
|
|
31
|
+
declared which of its columns are safe to publish.**
|
|
32
|
+
|
|
33
|
+
Everything you hand to `inertia()` is serialised into the HTML document, or returned as JSON
|
|
34
|
+
on an XHR visit. Anyone who views source reads all of it. That is how the protocol works, and
|
|
35
|
+
it is fine right up to the moment a model goes through it:
|
|
36
|
+
|
|
37
|
+
```ts fragment
|
|
38
|
+
// in a controller
|
|
39
|
+
return inertia("Trips/Show", { trip }); // ← every column of the row, in the page
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`trip.toJSON()` ships the whole row. If that row has an internal cost, a margin, a supplier
|
|
43
|
+
reference or a note somebody left about the customer, all of it is now on the customer's own
|
|
44
|
+
screen. Nothing fails, nothing logs, and the page looks right. This is the one mistake on this
|
|
45
|
+
page that does not announce itself.
|
|
46
|
+
|
|
47
|
+
### Declare the boundary at the model
|
|
48
|
+
|
|
49
|
+
The ORM's `hidden` and `visible` lists are honoured by `toJSON()`, which is exactly what
|
|
50
|
+
serialises a prop — so declaring them once at the model covers every route that ever passes it:
|
|
51
|
+
|
|
52
|
+
```ts fragment
|
|
53
|
+
import { Model, type Columns } from "zerotal/orm";
|
|
54
|
+
|
|
55
|
+
class Trip extends Model {
|
|
56
|
+
// Never leaves the server, whoever passes this model to whatever page.
|
|
57
|
+
static hidden: Columns<Trip>[] = ["cost_cents", "markup_percent", "internal_notes"];
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`visible` is the allow-list form and takes precedence: state the columns a page may have, and a
|
|
62
|
+
column added to the table later is private by default rather than published by default.
|
|
63
|
+
|
|
64
|
+
In development, passing a model that declares neither list writes a warning naming the model and
|
|
65
|
+
the number of fields it is about to publish. It fires once per model class and goes quiet as soon
|
|
66
|
+
as either list exists.
|
|
67
|
+
|
|
68
|
+
### When a route needs its own shape
|
|
69
|
+
|
|
70
|
+
A `hidden` list is one decision for the whole app. Where two audiences need different columns of
|
|
71
|
+
the same model, project per route and keep the dangerous fields out of the client-facing shape
|
|
72
|
+
entirely:
|
|
73
|
+
|
|
74
|
+
```ts fragment
|
|
75
|
+
// in a controller
|
|
76
|
+
function forCustomer(trip: Trip) {
|
|
77
|
+
return { id: trip.id, title: trip.title, total_cents: trip.total_cents };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function forOps(trip: Trip, options: { showCost: boolean }) {
|
|
81
|
+
return {
|
|
82
|
+
...forCustomer(trip),
|
|
83
|
+
// Absent, not zeroed. A zero looks like a fact; a missing key cannot be misread.
|
|
84
|
+
...(options.showCost ? { cost_cents: trip.cost_cents } : {}),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Prefer omitting a field over blanking it. A `cost_cents: 0` in page source reads as "this trip
|
|
90
|
+
cost us nothing", and somebody will eventually believe it.
|
|
91
|
+
|
|
28
92
|
## Partial reloads
|
|
29
93
|
|
|
30
94
|
On a visit to the _same page_, the client can request a subset of props with `only` / `except`.
|
|
@@ -501,6 +565,12 @@ still override one, and no page is forced to pass it.
|
|
|
501
565
|
- **Before you rebuild the registry**, every name and every prop bag compiles, as
|
|
502
566
|
it always did.
|
|
503
567
|
|
|
568
|
+
## Types
|
|
569
|
+
|
|
570
|
+
`PageObject` is what Inertia serialises into the page — component, props, url and version.
|
|
571
|
+
`MergeConfig` and `ScrollConfig` configure `merge()` and `scroll()`, and `PaginatorLike` is what
|
|
572
|
+
`scroll()` accepts from a paginator so the ORM's own and a hand-built one both work.
|
|
573
|
+
|
|
504
574
|
## Next steps
|
|
505
575
|
|
|
506
576
|
- [Inertia overview](/docs/inertia) — the guide's front page and the rest of the sections.
|
package/docs/inertia/ssr.md
CHANGED
|
@@ -75,27 +75,112 @@ export class PostController {
|
|
|
75
75
|
### Requirements
|
|
76
76
|
|
|
77
77
|
- `react-dom/server` ≥ 18 (for `renderToReadableStream`)
|
|
78
|
+
- `@inertiajs/react` — the same adapter the browser entry point uses; the server
|
|
79
|
+
renders through its `<App>` so `<Head>` works (see below)
|
|
78
80
|
- The HTML template must contain `<!-- @inertia -->`
|
|
79
81
|
- The page component must exist under your pages directory (`resources/js/pages/<component>.tsx`)
|
|
80
82
|
|
|
81
|
-
It throws if the template hasn't loaded, or if the component name contains path
|
|
82
|
-
traversal (`..` or a leading `/`).
|
|
83
|
-
|
|
84
83
|
### inertia vs. inertiaStream
|
|
85
84
|
|
|
86
|
-
| Criterion | `inertia()`
|
|
87
|
-
| -------------- |
|
|
88
|
-
| Return type | `Promise<void>`
|
|
89
|
-
| Rendering |
|
|
90
|
-
| Response body | Fully buffered string
|
|
91
|
-
| TTFB |
|
|
92
|
-
|
|
|
85
|
+
| Criterion | `inertia()` | `inertiaStream()` |
|
|
86
|
+
| -------------- | ------------------------ | ---------------------------------- |
|
|
87
|
+
| Return type | `Promise<void>` | `Promise<void>` |
|
|
88
|
+
| Rendering | None — empty root + JSON | Streaming `renderToReadableStream` |
|
|
89
|
+
| Response body | Fully buffered string | Streaming `ReadableStream` |
|
|
90
|
+
| TTFB | Immediate | After the shell is ready |
|
|
91
|
+
| Page `<Head>` | Client only | Collected into the served `<head>` |
|
|
92
|
+
| XHR navigation | JSON (the normal path) | N/A — only the first-page document |
|
|
93
93
|
|
|
94
94
|
For XHR navigations (`X-Inertia: true`), keep using `inertia()` — streaming only
|
|
95
95
|
benefits the initial HTML document load.
|
|
96
96
|
|
|
97
97
|
> **Tip** — Stream the heaviest landing pages and leave everything else on `inertia()`.
|
|
98
98
|
|
|
99
|
+
## Page metadata: `<Head>` on the server
|
|
100
|
+
|
|
101
|
+
Both server-rendered paths — `inertiaStream()` and the `/__ssr` endpoint — collect
|
|
102
|
+
whatever your page's `<Head>` declares and splice it into the template's `<head>`
|
|
103
|
+
before the response goes out. A page writes its metadata once, in the component, and
|
|
104
|
+
gets it in the HTML as well as in the browser:
|
|
105
|
+
|
|
106
|
+
```tsx fragment
|
|
107
|
+
// resources/js/pages/Trips/Show.tsx
|
|
108
|
+
import { Head } from "@inertiajs/react";
|
|
109
|
+
|
|
110
|
+
export default function Show({ trip }) {
|
|
111
|
+
return (
|
|
112
|
+
<>
|
|
113
|
+
<Head>
|
|
114
|
+
<title>{trip.name}</title>
|
|
115
|
+
<meta name="description" content={trip.summary} />
|
|
116
|
+
<meta property="og:title" content={trip.name} />
|
|
117
|
+
<meta property="og:image" content={trip.heroUrl} />
|
|
118
|
+
</Head>
|
|
119
|
+
…
|
|
120
|
+
</>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
An injected tag **replaces** the template's tag of the same identity rather than
|
|
126
|
+
being added after it — `<title>` by being a title, `<meta>` by its `name` or
|
|
127
|
+
`property`. That is not a detail: a document with two `<title>` tags is a document
|
|
128
|
+
with the _first_ one, so an appended title would be present, correct and ignored.
|
|
129
|
+
Anything the template does not already declare is appended before `</head>`.
|
|
130
|
+
|
|
131
|
+
Two things it does not do:
|
|
132
|
+
|
|
133
|
+
- **The title callback is client-side.** `createInertiaApp({ title })` in your
|
|
134
|
+
browser entry point is not visible to the server, so a page rendering
|
|
135
|
+
`<Head><title>Kruger</title></Head>` serves `Kruger` and the browser then shows
|
|
136
|
+
`Kruger — App`. Put the suffix in the `<Head>` itself if the served title matters
|
|
137
|
+
to you, which for a link preview it usually does.
|
|
138
|
+
- **`inertia()` does not render, so it does not collect.** A page returned through
|
|
139
|
+
plain `inertia()` sends the template's `<head>` as written. See
|
|
140
|
+
[What a crawler sees](#what-a-crawler-sees).
|
|
141
|
+
|
|
142
|
+
## What a crawler sees
|
|
143
|
+
|
|
144
|
+
`inertia()` — the default — **does not server-render the component at all.** Its
|
|
145
|
+
response body is the template with an empty root and the page object beside it:
|
|
146
|
+
|
|
147
|
+
```html
|
|
148
|
+
<body>
|
|
149
|
+
<div id="app"></div>
|
|
150
|
+
<script type="application/json" data-page="app">
|
|
151
|
+
{ … }
|
|
152
|
+
</script>
|
|
153
|
+
</body>
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
That is the normal Inertia arrangement and it is the right default: the page is
|
|
157
|
+
built by the client, and every navigation after the first is JSON. But it means the
|
|
158
|
+
served document contains **a title and a JSON blob**, and it is worth knowing which
|
|
159
|
+
readers of your site run JavaScript and which do not:
|
|
160
|
+
|
|
161
|
+
| Reader | Runs JavaScript | Sees your page |
|
|
162
|
+
| ---------------------------------------------------- | --------------------- | ----------------- |
|
|
163
|
+
| A browser | yes | yes |
|
|
164
|
+
| Googlebot, Bingbot | yes, on a second pass | yes, later |
|
|
165
|
+
| WhatsApp, Slack, iMessage, X, Facebook link previews | **no** | title + meta only |
|
|
166
|
+
| `curl`, uptime checks, most RSS and reader tools | **no** | title + meta only |
|
|
167
|
+
|
|
168
|
+
So the link preview a page produces is decided entirely by its `<head>` — which is
|
|
169
|
+
the template's, identically, on every page, unless you do one of these:
|
|
170
|
+
|
|
171
|
+
1. **Switch the page to `inertiaStream()`.** The component is rendered, `<Head>` is
|
|
172
|
+
collected, and the served `<head>` is the page's own. This is the smallest change
|
|
173
|
+
and the one to reach for on pages that get shared.
|
|
174
|
+
2. **Turn on endpoint SSR** (`ssr: true`) for the whole app.
|
|
175
|
+
3. **Set the tags in middleware**, if the metadata is server-side data the component
|
|
176
|
+
does not otherwise need.
|
|
177
|
+
|
|
178
|
+
`curl` is also how most people first check whether a deploy worked. An empty
|
|
179
|
+
`<div id="app">` in that output is not a broken deploy.
|
|
180
|
+
|
|
181
|
+
It throws if the template hasn't loaded, or if the component name contains path
|
|
182
|
+
traversal (`..` or a leading `/`).
|
|
183
|
+
|
|
99
184
|
## Next steps
|
|
100
185
|
|
|
101
186
|
- [Inertia overview](/docs/inertia) — the guide's front page and the rest of the sections.
|
package/docs/lock.md
CHANGED
|
@@ -447,6 +447,21 @@ sees an isolated, deterministic lock table with no network or file I/O.
|
|
|
447
447
|
| `exists()` | `exists(key: string): Promise<boolean>` | `true` if the lock is currently held. |
|
|
448
448
|
| `dispose?()` | `dispose?(): void` | Release background resources (timers, connections). |
|
|
449
449
|
|
|
450
|
+
### Types
|
|
451
|
+
|
|
452
|
+
| Type | What it is |
|
|
453
|
+
| ------------------- | ---------------------------------------------------------------------------------- |
|
|
454
|
+
| `LockedCallback<T>` | What `try` and `block` run while holding the lock. |
|
|
455
|
+
| `TryOptions` | `try`'s options — the TTL and whether a failure throws or returns. |
|
|
456
|
+
| `RefreshOptions` | How a long-running hold extends its own TTL rather than letting it lapse mid-work. |
|
|
457
|
+
|
|
458
|
+
The three built-in drivers are exported under their own names, so a custom driver can wrap one
|
|
459
|
+
rather than reimplement it: `MemoryLockDriver` (single process — the default, and wrong the
|
|
460
|
+
moment there are two), `SqliteLockDriver` (a file every process on the box can see) and
|
|
461
|
+
`RedisLockDriver` (across machines).
|
|
462
|
+
|
|
463
|
+
Picking the wrong one fails in the way locks always do: not at all, until there are two workers.
|
|
464
|
+
|
|
450
465
|
## Next steps
|
|
451
466
|
|
|
452
467
|
- [Scheduler](/docs/scheduler#preventing-overlapping-runs) — `withoutOverlapping` builds on the same idea for cron tasks.
|
package/docs/logger.md
CHANGED
|
@@ -171,6 +171,44 @@ export default LoggingConfig({
|
|
|
171
171
|
});
|
|
172
172
|
```
|
|
173
173
|
|
|
174
|
+
### Writing your own channel
|
|
175
|
+
|
|
176
|
+
A channel is one method. Implement `LogChannel` and register it as a driver when the five
|
|
177
|
+
built-ins do not reach where you need entries to go — a hosted log service, a socket, a table:
|
|
178
|
+
|
|
179
|
+
```typescript
|
|
180
|
+
import type { LogChannel, LogEntry } from "zerotal/logger";
|
|
181
|
+
|
|
182
|
+
class WebhookChannel implements LogChannel {
|
|
183
|
+
constructor(private readonly url: string) {}
|
|
184
|
+
|
|
185
|
+
async write(entry: LogEntry): Promise<void> {
|
|
186
|
+
// Failures are swallowed on purpose: a logging sink that throws turns a
|
|
187
|
+
// warning into an outage, and the entry it was carrying is lost either way.
|
|
188
|
+
await fetch(this.url, { method: "POST", body: JSON.stringify(entry) }).catch(() => {});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
The built-ins implement the same interface and are exported, so a custom channel can wrap one
|
|
194
|
+
rather than reimplement it — `StackChannel` is itself only a fan-out over other channels.
|
|
195
|
+
|
|
196
|
+
### Types
|
|
197
|
+
|
|
198
|
+
| Type | What it is |
|
|
199
|
+
| --------------- | ------------------------------------------------------------------------------ |
|
|
200
|
+
| `LogLevel` | `"debug" \| "info" \| "warn" \| "error" \| "fatal"` — the severity ladder. |
|
|
201
|
+
| `LogEntry` | One record: level, message, context, scope, timestamp, and any captured error. |
|
|
202
|
+
| `LogChannel` | The one-method sink contract: `write(entry): Promise<void>`. |
|
|
203
|
+
| `BoundLogger` | A logger pinned to a channel and/or a fixed context bag. |
|
|
204
|
+
| `ChannelConfig` | The discriminated union of the five driver configs below. |
|
|
205
|
+
| `LoggerOptions` | What `LoggingConfig()` accepts. |
|
|
206
|
+
| `TableData` | Rows for `Log.table()`. |
|
|
207
|
+
|
|
208
|
+
The five built-in channels are exported under their own names — `ConsoleChannel`,
|
|
209
|
+
`SingleChannel`, `DailyChannel`, `StackChannel`, `NullChannel` — each matching the `driver`
|
|
210
|
+
value in the table above.
|
|
211
|
+
|
|
174
212
|
## The Log facade
|
|
175
213
|
|
|
176
214
|
`Log` is a static proxy over the `LogManager` singleton. Use it anywhere —
|
package/docs/middleware.md
CHANGED
|
@@ -206,6 +206,37 @@ The package ships several middleware you can drop straight into `app.use([...])`
|
|
|
206
206
|
or a route's middleware array. Each extends `BaseMiddleware`, so `.with({ … })`
|
|
207
207
|
bakes options into a zero-argument class.
|
|
208
208
|
|
|
209
|
+
### Names the framework already occupies
|
|
210
|
+
|
|
211
|
+
Middleware live in a flat namespace: your `app/middleware/` classes are discovered by
|
|
212
|
+
class name, and so are the ones a package exports. Naming one of yours after one of
|
|
213
|
+
these is not caught as a conflict — it surfaces later as a type error somewhere that
|
|
214
|
+
does not mention either file, which is a confusing way to learn that
|
|
215
|
+
`TwoFactorMiddleware` was taken.
|
|
216
|
+
|
|
217
|
+
The full list, so you can check before you name:
|
|
218
|
+
|
|
219
|
+
| Middleware | Package |
|
|
220
|
+
| -------------------------------------------------------------------------------------- | -------------------------------- |
|
|
221
|
+
| `CorsMiddleware`, `SecureHeadersMiddleware`, `ThrottleMiddleware`, `WebhookMiddleware` | `@zerotal/core` |
|
|
222
|
+
| `AuthMiddleware`, `GuestMiddleware`, `PersistUserMiddleware`, `RememberMeMiddleware` | `@zerotal/auth` |
|
|
223
|
+
| `BasicAuthMiddleware`, `BearerTokenMiddleware`, `JwtGuardMiddleware` | `@zerotal/auth` |
|
|
224
|
+
| `RequireRoleMiddleware`, `RequirePermissionMiddleware`, `TwoFactorMiddleware` | `@zerotal/auth` |
|
|
225
|
+
| `ValidateSignatureMiddleware` | `@zerotal/auth` |
|
|
226
|
+
| `SessionMiddleware`, `CsrfMiddleware`, `AuthSessionMiddleware` | `@zerotal/session` |
|
|
227
|
+
| `InertiaMiddleware`, `PrecognitionMiddleware` | `@zerotal/inertia` |
|
|
228
|
+
| `AdminGuardMiddleware`, `AdminAbilityMiddleware` | `@zerotal/admin` |
|
|
229
|
+
| `MonitorAuthMiddleware`, `MonitorPayloadMiddleware` | `@zerotal/monitor` |
|
|
230
|
+
| `IdempotencyMiddleware` | `@zerotal/cache` |
|
|
231
|
+
| `LocaleMiddleware` | `@zerotal/i18n` |
|
|
232
|
+
| `EnsureTenancyMiddleware` | `@zerotal/tenancy` |
|
|
233
|
+
| `TelemetryMiddleware` | `@zerotal/telemetry` |
|
|
234
|
+
| `BaseMiddleware` | `@zerotal/core` (the base class) |
|
|
235
|
+
|
|
236
|
+
If yours does something different from the framework's, say so in the name rather
|
|
237
|
+
than shadowing it — `RequireTwoFactorMiddleware` for "fence the console until staff
|
|
238
|
+
have enrolled" reads better than `TwoFactorMiddleware` anyway, and cannot collide.
|
|
239
|
+
|
|
209
240
|
### CorsMiddleware
|
|
210
241
|
|
|
211
242
|
```ts fragment
|
package/docs/migrations.md
CHANGED
|
@@ -81,6 +81,13 @@ bun zt migrate:rollback
|
|
|
81
81
|
bun zt migrate:status
|
|
82
82
|
```
|
|
83
83
|
|
|
84
|
+
`bun zt doctor` warns when migrations on disk have not run, and names them. It is a warning
|
|
85
|
+
rather than a failure — pending migrations are the ordinary state of a checkout that just
|
|
86
|
+
pulled — but it is the cheapest moment to hear about them: the alternative is finding out from
|
|
87
|
+
a request that fails with `no such table`, an error whose stack is entirely framework frames.
|
|
88
|
+
The [development error page](/docs/errors#missing-tables-and-columns) answers the same question
|
|
89
|
+
after the fact, and can run them for you.
|
|
90
|
+
|
|
84
91
|
`migrate --fresh`, `migrate:fresh` and `migrate:refresh` all do the same thing:
|
|
85
92
|
roll everything back through each migration's `down()`, then re-run from scratch.
|
|
86
93
|
`migrate:refresh` exists because that is the name the command has elsewhere,
|
|
@@ -521,6 +528,46 @@ test("backfills a slug for every existing post", async () => {
|
|
|
521
528
|
| `hasTable` | `hasTable(table: string): Promise<boolean>` | Whether a table exists. |
|
|
522
529
|
| `hasColumn` | `hasColumn(table: string, column: string): Promise<boolean>` | Whether a column exists. |
|
|
523
530
|
|
|
531
|
+
### Types
|
|
532
|
+
|
|
533
|
+
The builders a migration's `up()` receives, and the shapes they take:
|
|
534
|
+
|
|
535
|
+
| Type | What it is |
|
|
536
|
+
| ------------------------ | ---------------------------------------------------------------------------------- |
|
|
537
|
+
| `ColumnBuilder` | What every `table.string(…)` returns — the chain that adds modifiers. |
|
|
538
|
+
| `ColumnOptions` | Length, precision and the other per-type settings a column accepts. |
|
|
539
|
+
| `ColumnShorthand` | The short forms (`"string"`, `"integer"`, …) accepted where a full builder fits. |
|
|
540
|
+
| `ForeignIdColumnBuilder` | What `table.foreignId(…)` returns, adding `.constrained()` to the chain. |
|
|
541
|
+
| `ForeignKeyBuilder` | The `.references(…).on(…)` chain, and where `onDelete` / `onUpdate` are set. |
|
|
542
|
+
| `FKAction` | `"cascade" \| "restrict" \| "set null" \| "no action"` — what a FK does on change. |
|
|
543
|
+
| `TableOptions` | Table-level settings: engine, charset, and whether it is temporary. |
|
|
544
|
+
|
|
545
|
+
### Running migrations yourself
|
|
546
|
+
|
|
547
|
+
`MigrationRunner` is the class behind `zt migrate`. Reach for it when you need the runner
|
|
548
|
+
somewhere a command cannot go — a test harness, a deploy script, a tenant provisioner that
|
|
549
|
+
migrates a database it has just created:
|
|
550
|
+
|
|
551
|
+
```typescript fragment
|
|
552
|
+
import { MigrationRunner } from "@zerotal/orm";
|
|
553
|
+
|
|
554
|
+
const runner = new MigrationRunner({ connection });
|
|
555
|
+
const ran = await runner.run(entries); // names of the migrations applied
|
|
556
|
+
const status = await runner.status(entries); // ran / pending, per migration
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
| Type | What it is |
|
|
560
|
+
| ----------------- | -------------------------------------------------------------------------- |
|
|
561
|
+
| `MigrationEntry` | One migration handed to the runner: its name and its instance. |
|
|
562
|
+
| `MigrationRecord` | A row of the `migrations` table — name, batch, and when it ran. |
|
|
563
|
+
| `MigrationStatus` | What `status()` reports per migration: whether it ran, and in which batch. |
|
|
564
|
+
| `MigrationError` | Thrown when a migration fails, carrying which one and what it was doing. |
|
|
565
|
+
|
|
566
|
+
`runner.willRollBackOnFailure` says whether this engine has transactional DDL. On MySQL it is
|
|
567
|
+
false — every DDL statement implicitly commits, so a migration that fails part-way leaves the
|
|
568
|
+
half that succeeded. `zt migrate` warns about this before it starts, while taking a backup is
|
|
569
|
+
still an option.
|
|
570
|
+
|
|
524
571
|
## Next steps
|
|
525
572
|
|
|
526
573
|
- [ORM](/docs/orm) — the models your migrations build tables for.
|