@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.
- 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 +125 -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 +103 -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 +99 -0
- package/docs/i18n.md +5 -0
- package/docs/inertia/props.md +70 -0
- package/docs/lock.md +15 -0
- package/docs/logger.md +38 -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 +39 -4
- package/docs/responses.md +23 -0
- package/docs/routing.md +16 -0
- package/docs/scheduler.md +11 -0
- 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 +6 -0
- package/docs/validator.md +9 -0
- package/docs/view.md +6 -0
- 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.
|
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/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/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.
|
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.
|
package/docs/notifications.md
CHANGED
|
@@ -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.
|
package/docs/orm/lifecycle.md
CHANGED
|
@@ -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.
|
package/docs/orm/queries.md
CHANGED
|
@@ -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.
|
package/docs/rate-limiting.md
CHANGED
|
@@ -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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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.
|