@zerotal/arch 1.8.1 → 1.9.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 (56) hide show
  1. package/docs/admin/actions.md +15 -0
  2. package/docs/admin/auth.md +10 -0
  3. package/docs/admin/dashboard.md +12 -0
  4. package/docs/admin/extending-ui.md +14 -0
  5. package/docs/admin/forms.md +15 -0
  6. package/docs/admin/operations.md +12 -0
  7. package/docs/admin/resources.md +6 -0
  8. package/docs/admin/tables.md +21 -0
  9. package/docs/audit.md +5 -0
  10. package/docs/authentication.md +110 -1
  11. package/docs/broadcasting/references.md +17 -0
  12. package/docs/cache.md +5 -0
  13. package/docs/carbon.md +5 -0
  14. package/docs/changelog.md +125 -0
  15. package/docs/client/index.md +17 -0
  16. package/docs/commands.md +6 -0
  17. package/docs/components.md +73 -0
  18. package/docs/config-system.md +54 -0
  19. package/docs/cookies.md +6 -0
  20. package/docs/deployment.md +103 -13
  21. package/docs/devtools.md +5 -0
  22. package/docs/email-verification.md +26 -1
  23. package/docs/encryption.md +21 -0
  24. package/docs/errors.md +2 -0
  25. package/docs/flow/components.md +54 -0
  26. package/docs/flow/forms.md +57 -0
  27. package/docs/flow/references.md +14 -0
  28. package/docs/getting-started.md +38 -0
  29. package/docs/health.md +19 -0
  30. package/docs/helpers.md +99 -0
  31. package/docs/i18n.md +5 -0
  32. package/docs/inertia/props.md +70 -0
  33. package/docs/lock.md +15 -0
  34. package/docs/logger.md +38 -0
  35. package/docs/migrations.md +47 -0
  36. package/docs/monitor.md +59 -0
  37. package/docs/notifications.md +11 -0
  38. package/docs/orm/casts.md +6 -0
  39. package/docs/orm/lifecycle.md +18 -0
  40. package/docs/orm/queries.md +10 -0
  41. package/docs/orm/relationships.md +30 -0
  42. package/docs/queue.md +10 -0
  43. package/docs/rate-limiting.md +39 -4
  44. package/docs/responses.md +23 -0
  45. package/docs/routing.md +16 -0
  46. package/docs/scheduler.md +11 -0
  47. package/docs/session.md +6 -0
  48. package/docs/social.md +10 -0
  49. package/docs/storage.md +21 -0
  50. package/docs/support-policy.md +13 -1
  51. package/docs/telemetry.md +8 -0
  52. package/docs/tenancy.md +6 -0
  53. package/docs/testing/index.md +6 -0
  54. package/docs/validator.md +9 -0
  55. package/docs/view.md +6 -0
  56. package/package.json +3 -3
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
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.
@@ -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/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 —
@@ -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.
package/docs/monitor.md CHANGED
@@ -262,6 +262,40 @@ Every colour in the panel resolves to a token — `bg-card`, `text-muted-foregro
262
262
 
263
263
  Practically, this means re-branding the monitor is a few CSS variables rather than a fork, and a contributed section written against the same tokens matches the built-in ones for free.
264
264
 
265
+ ## The snapshot shape
266
+
267
+ **Export JSON** hands you a `MonitorSnapshot` — the whole panel for the selected range, as data.
268
+ It is worth knowing the shape if you post it somewhere, diff two of them, or drive an alerting
269
+ integration off it rather than off the built-in thresholds.
270
+
271
+ `range` is a `MonitorRange`, and the rest is one field per thing the panel draws:
272
+
273
+ | Area | Fields and their row types |
274
+ | -------------------- | ------------------------------------------------------------------------------------------------------ |
275
+ | Live | `pulse: PulseStats` — in-flight requests, connections, rate, error rate. Not windowed. |
276
+ | Overview | `statCards: StatCard[]`, `percentiles: Percentile[]`, `apdex`, `throughput`, `slowRoutes: RouteStat[]` |
277
+ | Requests | `requests` / `slowRequests: RequestEntry[]`, `topUsers: UserUsage[]`, `topMemory: MemoryRoute[]` |
278
+ | Outgoing | `outgoingHttp: OutgoingHttp[]` |
279
+ | Queues | `queueStats: QueueMetric[]`, `queues: QueueRow[]`, `failedJobs: FailedJob[]`, `deadLetter: DeadJob[]` |
280
+ | Schedule | `scheduledJobs: ScheduledJob[]`, `scheduledRuns: FeedEvent[]`, `slowJobs` |
281
+ | Database | `dbStats: DbStat[]`, `slowQueries: SlowQuery[]`, `transactions: TxStats`, `nplusOnes: NPlusOne[]` |
282
+ | Exceptions | `exceptions: ExceptionGroup[]` — grouped, so one recurring error is one row. |
283
+ | Cache & realtime | `cache: CacheStats`, `realtime: RealtimeStats` |
284
+ | Mail & notifications | `mail`, `notifications: NotificationEntry[]` |
285
+ | Models | `models`, `recentModels: ModelEvent[]` |
286
+ | Health | `health: HealthEntry[]`, `gauges: Gauge[]`, `commands: CommandEntry[]` |
287
+ | Feeds | `security` / `logs: FeedEvent[]`, `alertHistory: AlertEntry[]` |
288
+
289
+ A few names carry more than their field suggests. `CacheKey` and `CacheStats` separate the hot
290
+ keys from the aggregate. `StatusClassCount` is the 2xx/3xx/4xx/5xx split behind an error rate.
291
+ `ConnectedClient` and `WsAction` are what `realtime` counts. `CheckIn` is a heartbeat from a
292
+ scheduled job that reported in, `UptimeCheck` an external probe, `RequestSpan` / `RequestQuery`
293
+ / `RequestPayload` / `RequestLog` the detail behind one `RequestEntry`, and `RouteDetail` the
294
+ per-route drill-down. `SystemMeta` and `StorageInfo` describe the machine rather than the app.
295
+
296
+ `Tone` (and `MonitorTone`) is the good/warn/bad colouring the panel applies to a stat, and
297
+ `AlertContext` is what an alert carries when it fires.
298
+
265
299
  ## Adding your own sections
266
300
 
267
301
  The panel owns the shell, the time-range selector, the storage and the retention policy — but it doesn't own the knowledge of what is worth watching about any given package. So it's a **host**: it publishes a write surface as the `monitor.panel` container binding, and a package pushes a section into it at boot.
@@ -318,6 +352,25 @@ export default MonitorConfig({
318
352
  });
319
353
  ```
320
354
 
355
+ ### Typing a section
356
+
357
+ Declaring the host's shape locally is the documented path and stays supported. If you would
358
+ rather have the real types — because your `resolve` is large enough that a typo in a column key
359
+ should be a compile error rather than a blank cell — they are exported:
360
+
361
+ | Type | What it is |
362
+ | -------------------- | -------------------------------------------------------------------------- |
363
+ | `MonitorSection` | The whole contribution: `id`, `label`, optional `group`, and `resolve`. |
364
+ | `MonitorSectionData` | What `resolve` returns — the `stats` and `tables` below. |
365
+ | `MonitorStat` | One figure: label, value, optional `percent` and `tone`. |
366
+ | `MonitorTable` | One table: title, columns, rows, and the `empty` line when there are none. |
367
+ | `MonitorTableColumn` | One column: `key`, `label`, and the `mono` / `align` presentation flags. |
368
+ | `MonitorRow` | One row — a record keyed by the columns' `key` values. |
369
+
370
+ Importing them means depending on `@zerotal/monitor`, which is the trade the structural form
371
+ exists to avoid. For a section of two stats and one table, the local interface is still the
372
+ better answer.
373
+
321
374
  ### Scheduled tasks
322
375
 
323
376
  `@zerotal/scheduler` ships the first contributed section. A cron task that silently stopped firing is one of the harder failures to notice — nothing errors, work just stops happening — so the section leads with counts of tasks that are failing or have never run, then lists every task with its cron expression, last result, duration and next due time. Install both providers and it appears under **Infrastructure**; no configuration.
@@ -369,6 +422,12 @@ Types: `MonitorConfigShape`, `ResolvedMonitorConfig`, `MonitorStoreOptions`,
369
422
  `@zerotal/monitor` ships no CLI commands. The panel is a route, not a console
370
423
  tool — everything is read through the browser or the Prometheus endpoint.
371
424
 
425
+ ### The metrics snapshot
426
+
427
+ `httpMetrics()` returns an `HttpMetricsSnapshot` — request counts, durations and status classes
428
+ since the process started. It is what the Prometheus endpoint renders, and it is exported so an
429
+ app can ship the same numbers somewhere the panel does not reach.
430
+
372
431
  ## Next steps
373
432
 
374
433
  - [Telemetry](/docs/telemetry) — export the same signal to an OTLP backend for long-term storage.
@@ -840,6 +840,17 @@ durable record is the database channel.
840
840
  | `OnDemandNotifiable` | The recipient `Notify.route()` builds — a destination with no model behind it. Its database rows are keyed to a random id nothing can query back, so on-demand notifications normally declare transport channels only. |
841
841
  | `RichLine` | The chainable line returned inside `MailMessage` for mixed formatting (`.text()`, `.color()`). |
842
842
 
843
+ ## Types
844
+
845
+ | Type | What it is |
846
+ | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
847
+ | `MailDriver` | The transport contract — implement it to send through something the built-ins do not cover. |
848
+ | `MailPayload` | One message as the driver receives it. |
849
+ | `MailAddress`, `AddressInput` | A recipient, and the forms one may be given in. |
850
+ | `MailAttachment` | A file on a message. |
851
+ | `TextStyle` | How the plain-text alternative is derived from the HTML. |
852
+ | `SmsConfigShape`, `TwilioConfigShape`, `VonageConfigShape` | SMS configuration, and the two providers' own settings. |
853
+
843
854
  ## Next steps
844
855
 
845
856
  - [Broadcasting](/docs/broadcasting) — the real-time broadcast channel and channel auth.
package/docs/orm/casts.md CHANGED
@@ -357,6 +357,12 @@ Cast options accepted by `@column()`:
357
357
  | `cast` | shorthand string, `{ get, set }`, or `CastContract` | The transform applied on read/write. |
358
358
  | `enumValues` | `Record<string, string \| number>` | The TS enum object, paired with `cast: "enum"`. |
359
359
 
360
+ ## Types
361
+
362
+ `CastField` is what a cast declaration holds. `EncryptedCastName` names the encrypted cast
363
+ variants, and `isEncryptedCast` says whether a given cast is one — which matters because an
364
+ encrypted column cannot be queried by value, only by a blind index kept beside it.
365
+
360
366
  ## Next steps
361
367
 
362
368
  - [ORM](/docs/orm) — defining models and columns.
@@ -413,6 +413,24 @@ the two are identical.
413
413
  | `static dispatchesEvents` | `Record<string, new (model) => object>` | Maps lifecycle keys to app-bus event classes. |
414
414
  | `static massPrune` | `boolean` (default `false`) | Permanently delete prunable rows instead of soft-deleting. |
415
415
 
416
+ ## Types
417
+
418
+ **State machines.** `StateMachine` guards a column against transitions that should not happen —
419
+ an order going from `shipped` back to `pending` is a bug, and the place to refuse it is the model
420
+ rather than every call site.
421
+
422
+ | Type | What it is |
423
+ | ----------------------------------------- | ---------------------------------------------------------------------- |
424
+ | `StateGuard` | The condition allowing one transition. |
425
+ | `TransitionCallback`, `TransitionContext` | What runs on a transition, and what it receives. |
426
+ | `TransitionResult` | Whether it happened. |
427
+ | `RejectTransition` | The refusal — a value, so a caller can branch on it rather than catch. |
428
+
429
+ **Errors.** `TransactionError` wraps a failure inside a transaction with what was being
430
+ attempted; `UnsupportedDialectError` is thrown when a driver genuinely cannot do something —
431
+ a signed URL on a local disk, an advisory lock on SQLite — rather than failing quietly and
432
+ returning nothing. `TransactionContext` is the ambient handle a transaction carries.
433
+
416
434
  ## Next steps
417
435
 
418
436
  - [ORM](/docs/orm) — model basics and the `dispatchesEvents` bridge.
@@ -683,6 +683,16 @@ Child models inherit all global scopes registered on a parent model.
683
683
  | `toRawSql` | `toRawSql(): string` | SQL with values inlined (logging only). |
684
684
  | `clone` | `clone(): this` | Copy the builder to branch conditions. |
685
685
 
686
+ ## Types
687
+
688
+ | Type | What it is |
689
+ | --------------------- | --------------------------------------------------------------- |
690
+ | `WhereOperator` | The comparison operators `where()` accepts. |
691
+ | `OrderDirection` | `asc` / `desc`. |
692
+ | `DatePart` | The parts date helpers can compare on — year, month, day. |
693
+ | `PaginateMeta` | The page, per-page, total and last-page a paginator reports. |
694
+ | `GlobalScopeCallback` | A scope applied to every query for a model until it is removed. |
695
+
686
696
  ## Next steps
687
697
 
688
698
  - [ORM](/docs/orm) — model definition, columns, and configuration.
@@ -432,6 +432,36 @@ Pivot collection methods on a `ManyToMany<T>` relation.
432
432
  | `sync` | `sync(ids[]): Promise<void>` | Replace all pivot rows with the given set. |
433
433
  | `toggle` | `toggle(id \| id[]): Promise<void>` | Attach missing ids and detach present ones. |
434
434
 
435
+ ### Types
436
+
437
+ Every decorator above returns a typed relation, and every one takes an options shape. Both are
438
+ exported, so a helper that builds relations or a signature that accepts one can be annotated.
439
+
440
+ | Type | What it is |
441
+ | -------------------- | ---------------------------------------------------------------------------------- |
442
+ | `HasOne<T>` | What `@hasOne` produces — one related record, or `null`. |
443
+ | `HasMany<T>` | What `@hasMany` produces — a queryable collection of related records. |
444
+ | `BelongsTo<T>` | What `@belongsTo` produces — the owning record, or `null`. |
445
+ | `RelationType` | The relation kinds as a union: `hasOne`, `hasMany`, `belongsTo`, `manyToMany`, … |
446
+ | `RelationDefinition` | One declared relation: its type, target model, and keys. |
447
+ | `RelationMetadata` | What the decorator records about a relation, read by eager loading and `whereHas`. |
448
+ | `RelationConstraint` | The callback form — `with({ posts: (q) => q.where(…) })`. |
449
+ | `WithLoaded<T, K>` | A model type narrowed to say which relations are loaded, so reading one is safe. |
450
+
451
+ The option shapes match their decorators: `HasManyThroughOptions`, `ManyToManyOptions`,
452
+ `MorphOneOptions`, `MorphManyOptions`, `MorphToOptions`, `MorphToManyOptions` and
453
+ `MorphedByManyOptions`.
454
+
455
+ `relationRegistry` is the map the decorators write into and eager loading reads back. It is
456
+ framework wiring rather than something an app calls, but it is exported because the testing
457
+ helpers reach for it.
458
+
459
+ > **`RelationNotLoadedError`** is thrown when you read a relation that was never loaded, rather
460
+ > than returning `undefined`. That is the whole reason the error exists: a silent `undefined`
461
+ > reads as "no related records" and is indistinguishable from a genuine empty result, so an
462
+ > N+1 you meant to fix becomes a page that quietly shows nothing. Load it with `with()`, or ask
463
+ > for it explicitly with `await post.load("author")`.
464
+
435
465
  ## Next steps
436
466
 
437
467
  - [ORM queries](/docs/orm/queries) — eager loading, `whereHas`, and aggregates in depth.
package/docs/queue.md CHANGED
@@ -550,6 +550,16 @@ try {
550
550
  process is draining, not that anything is broken, so the right response is to
551
551
  re-dispatch on the next boot rather than to fail the request.
552
552
 
553
+ ## Types
554
+
555
+ | Type | What it is |
556
+ | -------------------------------------------- | ----------------------------------------------------------------------------------------------- |
557
+ | `QueueDriver` | The contract a backend implements — implement it to queue somewhere the built-ins do not reach. |
558
+ | `JobRecord`, `JobStatus` | A queued job as stored, and where it is in its life. |
559
+ | `SerializedJob` | The wire form — what a driver actually persists. |
560
+ | `BatchOptions`, `BatchRecord`, `BatchStatus` | A batch's settings, its stored form, and its progress. |
561
+ | `WorkerPoolOptions`, `WorkerResult` | How many workers run and what one attempt returned. |
562
+
553
563
  ## Next steps
554
564
 
555
565
  - [Scheduler](/docs/scheduler) — run recurring jobs alongside the queue worker.
@@ -65,10 +65,45 @@ ThrottleMiddleware.with({
65
65
  | `keyResolver` | no | client IP | Function returning the rate-limit key for a request. |
66
66
  | `trustedProxies` | no | `undefined` | Number of trusted upstream proxies (see the warning below). |
67
67
 
68
- > **Danger** With `trustedProxies` left `undefined`, the limiter trusts the
69
- > leftmost `X-Forwarded-For` entry, which a client behind a real proxy can forge
70
- > to dodge the limit. Set `trustedProxies` to the exact number of proxies in
71
- > front of your server (`0` when there is none) so the real client IP is used.
68
+ `X-Forwarded-For` is written by the client, so it is consulted **only** when
69
+ `trustedProxies` says how many proxies sit in front of the app — the count is what says
70
+ which entry is not attacker-controlled. Left `undefined` (or `0`), the unspoofable socket
71
+ address is used.
72
+
73
+ > **Danger** — That default is right, and it is the wrong answer the moment you deploy
74
+ > behind a proxy. The socket address is then the _proxy's_ — `127.0.0.1` for every visitor
75
+ > — so everyone shares one bucket per form and the limiter inverts into the thing it was
76
+ > installed to prevent: one attacker making twenty bad sign-ins a minute locks the whole
77
+ > staff out of the console. Nothing fails; you put Caddy in front, everything works, and
78
+ > the limiter quietly stops telling people apart. Set `trustedProxies` to the number of
79
+ > proxies you actually run. `zt doctor` warns when a production-like deployment has a
80
+ > throttle and no `trustedProxies` — see [Deployment](/docs/deployment#behind-a-reverse-proxy).
81
+
82
+ ### One `.with()` call, one bucket
83
+
84
+ Each `.with()` call returns its own class, and the hit counter belongs to the class — so
85
+ **re-using one `.with()` export on two routes gives them a shared budget**:
86
+
87
+ ```typescript fragment
88
+ // One bucket: 5 attempts across BOTH forms.
89
+ const AuthThrottle = ThrottleMiddleware.with({ maxAttempts: 5, windowSeconds: 60 });
90
+ Router.post("/login", AuthController, "login", [AuthThrottle]);
91
+ Router.post("/two-factor", AuthController, "challenge", [AuthThrottle]);
92
+
93
+ // A bucket each, which is almost always what was meant.
94
+ Router.post("/login", AuthController, "login", [
95
+ ThrottleMiddleware.with({ maxAttempts: 5, windowSeconds: 60 }),
96
+ ]);
97
+ Router.post("/two-factor", AuthController, "challenge", [
98
+ ThrottleMiddleware.with({ maxAttempts: 5, windowSeconds: 60 }),
99
+ ]);
100
+ ```
101
+
102
+ The sharing is deliberate — it is what lets you spend one allowance across a group of
103
+ related routes on purpose — and it is not what a reader expects from a factory. On a sign-in
104
+ flow it bites: a handful of fumbled passwords can spend the allowance a legitimate person
105
+ needs to answer their second factor. Call `.with()` once per thing that deserves its own
106
+ budget.
72
107
 
73
108
  ## RateLimiter — named limiters
74
109
 
package/docs/responses.md CHANGED
@@ -327,6 +327,29 @@ Common status codes:
327
327
  | `ctx.redirect(url, 303)` | 303 | After POST/PUT/DELETE |
328
328
  | `ctx.redirect(url, 301)` | 301 | Permanent redirect |
329
329
 
330
+ ## Negotiating by client
331
+
332
+ One route, three audiences. `negotiate(ctx)` picks a branch from the `Accept` header and how the
333
+ request arrived, so a handler answers a browser, an API client and the console without three
334
+ copies of the logic:
335
+
336
+ ```typescript fragment
337
+ import { negotiate } from "zerotal/http";
338
+
339
+ await negotiate(http)({
340
+ web: () => http.redirect("/dashboard"),
341
+ api: () => http.json({ ok: true }),
342
+ cli: () => http.text("done"),
343
+ });
344
+ ```
345
+
346
+ `NegotiateMap` is that object; `WebContext`, `ApiContext` and `CliContext` are what each branch
347
+ receives. A missing branch falls through to `web`, because the browser is the audience most
348
+ likely to be looking.
349
+
350
+ This is what the framework's own error handler uses, which is why a 422 is a redirect-with-errors
351
+ for a form post and a JSON body for a fetch — see [Errors](/docs/errors#response-format-by-client-type).
352
+
330
353
  ## Next steps
331
354
 
332
355
  - [Requests Context](/docs/context) — read input from the incoming request.