@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.
Files changed (64) hide show
  1. package/api-surface.md +6 -6
  2. package/docs/admin/actions.md +15 -0
  3. package/docs/admin/auth.md +10 -0
  4. package/docs/admin/dashboard.md +12 -0
  5. package/docs/admin/extending-ui.md +14 -0
  6. package/docs/admin/forms.md +15 -0
  7. package/docs/admin/operations.md +12 -0
  8. package/docs/admin/resources.md +6 -0
  9. package/docs/admin/tables.md +21 -0
  10. package/docs/audit.md +5 -0
  11. package/docs/authentication.md +110 -1
  12. package/docs/broadcasting/references.md +17 -0
  13. package/docs/cache.md +5 -0
  14. package/docs/carbon.md +5 -0
  15. package/docs/changelog.md +281 -0
  16. package/docs/client/index.md +17 -0
  17. package/docs/commands.md +6 -0
  18. package/docs/components.md +73 -0
  19. package/docs/config-system.md +54 -0
  20. package/docs/cookies.md +6 -0
  21. package/docs/deployment.md +151 -13
  22. package/docs/devtools.md +5 -0
  23. package/docs/email-verification.md +26 -1
  24. package/docs/encryption.md +21 -0
  25. package/docs/errors.md +2 -0
  26. package/docs/flow/components.md +54 -0
  27. package/docs/flow/forms.md +57 -0
  28. package/docs/flow/references.md +14 -0
  29. package/docs/getting-started.md +38 -0
  30. package/docs/health.md +19 -0
  31. package/docs/helpers.md +150 -0
  32. package/docs/i18n.md +5 -0
  33. package/docs/inertia/middleware.md +44 -0
  34. package/docs/inertia/props.md +70 -0
  35. package/docs/inertia/ssr.md +95 -10
  36. package/docs/lock.md +15 -0
  37. package/docs/logger.md +38 -0
  38. package/docs/middleware.md +31 -0
  39. package/docs/migrations.md +47 -0
  40. package/docs/monitor.md +59 -0
  41. package/docs/notifications.md +11 -0
  42. package/docs/orm/casts.md +6 -0
  43. package/docs/orm/lifecycle.md +18 -0
  44. package/docs/orm/queries.md +10 -0
  45. package/docs/orm/relationships.md +30 -0
  46. package/docs/queue.md +10 -0
  47. package/docs/rate-limiting.md +84 -21
  48. package/docs/responses.md +23 -0
  49. package/docs/routing.md +16 -0
  50. package/docs/scheduler.md +82 -8
  51. package/docs/session.md +6 -0
  52. package/docs/social.md +10 -0
  53. package/docs/storage.md +21 -0
  54. package/docs/support-policy.md +13 -1
  55. package/docs/telemetry.md +8 -0
  56. package/docs/tenancy.md +6 -0
  57. package/docs/testing/index.md +105 -0
  58. package/docs/upgrade.md +48 -0
  59. package/docs/validator.md +9 -0
  60. package/docs/view.md +6 -0
  61. package/package.json +3 -3
  62. package/src/install/guidelines.ts +1 -1
  63. package/src/mcp/stdio.ts +3 -3
  64. 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
@@ -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.
@@ -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()` | `inertiaStream()` |
87
- | -------------- | ---------------------- | ---------------------------------- |
88
- | Return type | `Promise<void>` | `Promise<void>` |
89
- | Rendering | Buffered HTML string | Streaming `renderToReadableStream` |
90
- | Response body | Fully buffered string | Streaming `ReadableStream` |
91
- | TTFB | After full render | After the prefix is flushed |
92
- | XHR navigation | JSON (the normal path) | N/A only the first-page document |
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 —
@@ -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
@@ -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.