@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/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,53 @@ 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
+ **Counted from the right, and that is the whole of it.** Each proxy _appends_ the address
74
+ it received the request from, so the rightmost entries are the ones your own
75
+ infrastructure wrote and the leftmost is whatever the client sent. Reading the header
76
+ left-to-right — the obvious way, and how most hand-rolled versions do it — hands the
77
+ limiter's key to the attacker: they set `X-Forwarded-For: <your CFO's IP>`, spend the
78
+ budget, and the person whose address they borrowed is locked out of the form. A limiter
79
+ that can be aimed is worse than no limiter, because it looks like it is working.
80
+
81
+ > **Danger** — That default is right, and it is the wrong answer the moment you deploy
82
+ > behind a proxy. The socket address is then the _proxy's_ — `127.0.0.1` for every visitor
83
+ > — so everyone shares one bucket per form and the limiter inverts into the thing it was
84
+ > installed to prevent: one attacker making twenty bad sign-ins a minute locks the whole
85
+ > staff out of the console. Nothing fails; you put Caddy in front, everything works, and
86
+ > the limiter quietly stops telling people apart. Set `trustedProxies` to the number of
87
+ > proxies you actually run. `zt doctor` warns when a production-like deployment has a
88
+ > throttle and no `trustedProxies` — see [Deployment](/docs/deployment#behind-a-reverse-proxy).
89
+
90
+ ### One `.with()` call, one bucket
91
+
92
+ Each `.with()` call returns its own class, and the hit counter belongs to the class — so
93
+ **re-using one `.with()` export on two routes gives them a shared budget**:
94
+
95
+ ```typescript fragment
96
+ // One bucket: 5 attempts across BOTH forms.
97
+ const AuthThrottle = ThrottleMiddleware.with({ maxAttempts: 5, windowSeconds: 60 });
98
+ Router.post("/login", AuthController, "login", [AuthThrottle]);
99
+ Router.post("/two-factor", AuthController, "challenge", [AuthThrottle]);
100
+
101
+ // A bucket each, which is almost always what was meant.
102
+ Router.post("/login", AuthController, "login", [
103
+ ThrottleMiddleware.with({ maxAttempts: 5, windowSeconds: 60 }),
104
+ ]);
105
+ Router.post("/two-factor", AuthController, "challenge", [
106
+ ThrottleMiddleware.with({ maxAttempts: 5, windowSeconds: 60 }),
107
+ ]);
108
+ ```
109
+
110
+ The sharing is deliberate — it is what lets you spend one allowance across a group of
111
+ related routes on purpose — and it is not what a reader expects from a factory. On a sign-in
112
+ flow it bites: a handful of fumbled passwords can spend the allowance a legitimate person
113
+ needs to answer their second factor. Call `.with()` once per thing that deserves its own
114
+ budget.
72
115
 
73
116
  ## RateLimiter — named limiters
74
117
 
@@ -82,8 +125,9 @@ import { RateLimiter } from "zerotal";
82
125
  // 1000 req/hour per authenticated user (falls back to IP when unauthenticated)
83
126
  RateLimiter.for("api").limit(1000).every(3600).byUser().register();
84
127
 
85
- // 5 login attempts per minute, per IP
86
- RateLimiter.for("login").limit(5).every(60).byIp().register();
128
+ // 5 login attempts per minute, per IP. `.trustedProxies(1)` because this app is
129
+ // behind one reverse proxy — without it every visitor shares the proxy's address.
130
+ RateLimiter.for("login").limit(5).every(60).byIp().trustedProxies(1).register();
87
131
 
88
132
  // 500 req/min keyed by an API-key header (unknown key → per IP)
89
133
  RateLimiter.for("partner").limit(500).every(60).byApiKey("x-api-key").register();
@@ -105,12 +149,30 @@ RateLimiter.for("upload")
105
149
  Each `.by*()` call sets how requests are bucketed. The default (no `.by*()` call)
106
150
  is the client IP.
107
151
 
108
- | Method | Keys on | Falls back to |
109
- | -------------------- | ------------------------------------------- | ------------------------ |
110
- | `.byUser()` | `ctx.user.id` | IP when unauthenticated |
111
- | `.byApiKey(header?)` | `x-api-key` header (or a custom header) | IP when header is absent |
112
- | `.byIp()` | Socket IP `X-Forwarded-For` → `X-Real-IP` | `'unknown'` |
113
- | `.by(fn)` | Return value of your function | — |
152
+ | Method | Keys on | Falls back to |
153
+ | -------------------- | --------------------------------------- | ------------------------ |
154
+ | `.byUser()` | `ctx.user.id` | IP when unauthenticated |
155
+ | `.byApiKey(header?)` | `x-api-key` header (or a custom header) | IP when header is absent |
156
+ | `.byIp()` | Client IP (the explicit default) | `'unknown'` |
157
+ | `.by(fn)` | Return value of your function | — |
158
+
159
+ > **Danger** — **Every one of the built-in strategies can end up keying on an
160
+ > address**, including `.byUser()` and `.byApiKey()` — for a request with no user and
161
+ > no key, which on a login form is every request that matters. So a named limiter
162
+ > behind a reverse proxy needs `.trustedProxies(n)` for exactly the reason
163
+ > [`ThrottleMiddleware` does](#throttlemiddleware--inline): without it the address is
164
+ > the socket's, which is the _proxy's_, and every visitor shares one bucket. A
165
+ > `login` limiter of five attempts a minute becomes five attempts a minute for your
166
+ > whole user base, and one attacker locks everybody out.
167
+ >
168
+ > ```typescript fragment
169
+ > // config/limiters.ts — behind one reverse proxy
170
+ > RateLimiter.for("login").limit(5).every(60).byIp().trustedProxies(1).register();
171
+ > ```
172
+ >
173
+ > `zt doctor` reports a named limiter that keys on an address and was never told
174
+ > about a proxy. `.by(fn)` is yours — it is exempt, and resolving the address is on
175
+ > you if you use one.
114
176
 
115
177
  ### Applying a named limiter
116
178
 
@@ -228,15 +290,16 @@ await app.post("/login", { email: "a@b.c" }, { "X-Forwarded-For": "10.0.0.7" });
228
290
 
229
291
  ### `LimiterDefinition` (fluent)
230
292
 
231
- | Method | Signature | Description |
232
- | ---------- | -------------------------------------------- | ---------------------------------------------------------- |
233
- | `limit` | `limit(max: number): this` | Maximum requests in the window (default `60`). |
234
- | `every` | `every(seconds: number): this` | Window duration in seconds (default `60`). |
235
- | `byUser` | `byUser(): this` | Key by `ctx.user.id`; IP when unauthenticated. |
236
- | `byApiKey` | `byApiKey(header?: string): this` | Key by header value (default `x-api-key`); IP when absent. |
237
- | `byIp` | `byIp(): this` | Key by client IP (the explicit default). |
238
- | `by` | `by(fn: (ctx: HttpContext) => string): this` | Key by your own resolver. |
239
- | `register` | `register(): this` | Register the limiter with the global registry. |
293
+ | Method | Signature | Description |
294
+ | ---------------- | -------------------------------------------- | ---------------------------------------------------------- |
295
+ | `limit` | `limit(max: number): this` | Maximum requests in the window (default `60`). |
296
+ | `every` | `every(seconds: number): this` | Window duration in seconds (default `60`). |
297
+ | `byUser` | `byUser(): this` | Key by `ctx.user.id`; IP when unauthenticated. |
298
+ | `byApiKey` | `byApiKey(header?: string): this` | Key by header value (default `x-api-key`); IP when absent. |
299
+ | `byIp` | `byIp(): this` | Key by client IP (the explicit default). |
300
+ | `by` | `by(fn: (ctx: HttpContext) => string): this` | Key by your own resolver. |
301
+ | `trustedProxies` | `trustedProxies(count: number): this` | Proxies in front of the app — required behind one. |
302
+ | `register` | `register(): this` | Register the limiter with the global registry. |
240
303
 
241
304
  ### `ThrottleMiddleware`
242
305
 
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.
package/docs/routing.md CHANGED
@@ -1081,6 +1081,22 @@ Every route registration returns a chainable handle:
1081
1081
  | `bun zt route:list` | — | Print every route (`-m` method, `-p` path, `--name`, `-v` middleware). |
1082
1082
  | `bun zt route:types` | `--check` | Write `types/routes.generated.ts`; `--check` fails when it is stale. |
1083
1083
 
1084
+ ## Types
1085
+
1086
+ | Type | What it is |
1087
+ | -------------------- | --------------------------------------------------------------------------------------------------------- |
1088
+ | `HttpMethod` | The verbs a route can answer on. |
1089
+ | `HttpResponse` | What a handler may return when it does not write to `ctx.response`. |
1090
+ | `ControllerResponse` | A controller action's return type — `void`, a `ResponseBuilder`, or a `MarkdownBuilder`. |
1091
+ | `RouteParams` | The params a pattern captures, as a record. |
1092
+ | `ParamsOf<P>` | The params of one pattern, derived from the pattern string — what makes `route()` refuse a missing `:id`. |
1093
+ | `RouteRegistry` | The generated name → pattern table, augmented by `types/routes.generated.ts`. |
1094
+ | `RouteMiddleware` | What a route's middleware list accepts — a class, an array, or the method map. |
1095
+ | `GroupOptions` | `Router.group({ prefix, middleware, domain })`. |
1096
+ | `RoutingConfig` | The `routing` config namespace. |
1097
+ | `FileRoutingConfig` | File-router settings within it. |
1098
+ | `WebSocketHandlers` | The handler set a WebSocket route registers. |
1099
+
1084
1100
  ## Next steps
1085
1101
 
1086
1102
  - [Controllers](/docs/controllers) — move route logic out of closures.
package/docs/scheduler.md CHANGED
@@ -61,17 +61,43 @@ import { SchedulerConfig } from "@zerotal/scheduler";
61
61
  import { env } from "zerotal";
62
62
 
63
63
  export default SchedulerConfig({
64
- timezone: env("APP_TIMEZONE", "UTC"),
64
+ timezone: env("APP_TIMEZONE", "Africa/Johannesburg"),
65
65
  });
66
66
  ```
67
67
 
68
- | Field | Required | Default | Description |
69
- | ---------- | -------- | ------- | --------------------------------------------------------------------------------------- |
70
- | `timezone` | no | `"UTC"` | Informational only `Bun.cron` uses the system timezone. Set per task with `timezone`. |
68
+ | Field | Required | Default | Description |
69
+ | ---------- | -------- | --------------- | ---------------------------------------------------------------------------------- |
70
+ | `timezone` | no | the system zone | IANA zone every cron expression is read in, unless a task sets its own `timezone`. |
71
71
 
72
- > **Note** — The config `timezone` is informational. To evaluate a cron in a
73
- > specific zone, set `timezone` on the `Schedule` subclass or `.timezone(tz)` on a
74
- > facade task; that value is passed through to `Bun.cron`.
72
+ ## Timezones
73
+
74
+ A cron expression is a wall clock: `0 3 * * *` means three in the morning
75
+ _somewhere_. By default that somewhere is the server's zone. Set `scheduler.timezone`
76
+ to make it one zone for the whole app, or `timezone` on a single schedule to override
77
+ it:
78
+
79
+ ```typescript fragment
80
+ export class SendDailyReports extends Schedule {
81
+ cron = "0 8 * * *";
82
+ timezone = "Africa/Johannesburg"; // 08:00 there, whatever the server is on
83
+ }
84
+ ```
85
+
86
+ The zone is evaluated by Zerotal, not by `Bun.cron` — which reads the system zone and
87
+ has no option to change it. A zoned task ticks every minute and runs on the ticks
88
+ where its expression matches the clock in its own zone, so it stays correct across a
89
+ daylight-saving change rather than drifting by an hour twice a year. A minute is also
90
+ the finest granularity `Bun.cron` accepts, so nothing is given up.
91
+
92
+ Two consequences worth knowing:
93
+
94
+ - **A skipped hour skips the schedules inside it, and a repeated hour runs them
95
+ twice.** That is what every cron does. A task at `0 2 * * *` in a zone that springs
96
+ from 01:59 to 03:00 does not run that day.
97
+ - **An unknown zone name refuses at boot, loudly, and takes only its own task out.**
98
+ A registration failure used to propagate: the worker died during boot and
99
+ restart-looped, so one bad schedule stopped every schedule in the app. Now the
100
+ others start and the log names the one that did not.
75
101
 
76
102
  ## Defining schedules
77
103
 
@@ -134,7 +160,7 @@ Every setting is an optional property (or method) on your `Schedule` subclass:
134
160
  | `cron` | `string` | Cron expression (5- or 6-field). Set this **or** override `frequency()`. |
135
161
  | `frequency(every)` | method | Build the cadence fluently; return the task (see helpers below). |
136
162
  | `name` | `string` | Task name in `schedule:list` and logs. Defaults to the class name. |
137
- | `timezone` | `string` | IANA timezone the cron is evaluated in. |
163
+ | `timezone` | `string` | IANA timezone the cron is evaluated in — overrides `scheduler.timezone`. See [Timezones](#timezones). |
138
164
  | `withoutOverlapping` | `boolean \| OverlapLockOptions` | Skip a tick while a previous run is active; also takes a cross-process lock when a lock driver is configured. |
139
165
  | `environments` | `string[]` | Only run when `APP_ENV` is one of these. |
140
166
  | `inBackground` | `boolean` | Run the body without blocking the scheduler tick. |
@@ -489,6 +515,19 @@ override async onStarted(): Promise<void> {
489
515
  For production, run the worker as a separate process so it can be scaled, restarted,
490
516
  and monitored independently of the web server.
491
517
 
518
+ > **A web process says so when it is not running your schedules.** `app/schedules/`
519
+ > is only discovered in `worker` and `console`, so a web process skips it by not
520
+ > looking — which used to be completely silent, and is how an app runs for weeks in
521
+ > production with every schedule written and none of them ever firing. A boot line
522
+ > now names it:
523
+ >
524
+ > ```
525
+ > Skipping 3 file(s) in app/schedules — the "schedules" convention does not run in env=web (it runs in: worker, console).
526
+ > ```
527
+ >
528
+ > Seeing that on a web process is correct. Seeing it and having no worker running is
529
+ > the hole.
530
+
492
531
  ## References
493
532
 
494
533
  The `Scheduler` facade resolves the `scheduler` container binding — a
@@ -514,6 +553,30 @@ The `Scheduler` facade resolves the `scheduler` container binding — a
514
553
  | `stop` | `stop(): void` | Stop every running task. |
515
554
  | `tasks` | `get tasks(): ReadonlyMap<string, ScheduledTask>` | The registered tasks, keyed by name. |
516
555
 
556
+ ### Timezone helpers
557
+
558
+ The zone arithmetic the scheduler uses to evaluate a cron somewhere other than the
559
+ server, exported because an app doing its own time-window logic needs the same
560
+ answers.
561
+
562
+ | Export | Signature | Description |
563
+ | ------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
564
+ | `isValidTimeZone` | `isValidTimeZone(tz: string): boolean` | Whether this runtime knows the IANA zone. Check before storing one a user typed. |
565
+ | `wallClockIn` | `wallClockIn(date: Date, tz: string): Date` | The same instant, shifted so the Date's _local_ getters read that zone's clock face. |
566
+ | `CronExpression.matchesIn` | `matchesIn(date: Date, tz: string): boolean` | Whether the expression fires at `date`, read in `tz`. |
567
+ | `CronExpression.nextRunAfterIn` | `nextRunAfterIn(expr, from: Date, tz): Date \| null` | The next real instant the expression fires on that zone's clock — correct across a DST change. |
568
+
569
+ `wallClockIn` returns a Date that is a lie about the instant and true about the clock
570
+ face: its epoch value is off by the zone offset. Pass it to a field comparison, never
571
+ back to a caller.
572
+
573
+ ### Errors
574
+
575
+ | Error | Thrown when |
576
+ | ---------------------- | ------------------------------------------------------------------------------------------- |
577
+ | `SchedulerError` | Base class for everything this package throws. Catch it to catch them all. |
578
+ | `UnknownTimeZoneError` | A task declares a `timezone` this runtime does not know — at registration, naming the task. |
579
+
517
580
  ### ScheduledTask introspection
518
581
 
519
582
  | Member | Signature | Description |
@@ -525,6 +588,17 @@ The `Scheduler` facade resolves the `scheduler` container binding — a
525
588
  | `lastDurationMs` | `get lastDurationMs(): number \| undefined` | Duration of the last run in ms. |
526
589
  | `isRunning` | `get isRunning(): boolean` | `true` while a run is in flight. |
527
590
 
591
+ ## Types
592
+
593
+ | Type | What it is |
594
+ | --------------------------------------- | -------------------------------------------------------------------------------------------- |
595
+ | `CronExpression` | The schedule string a task declares. |
596
+ | `TaskGuard` | A condition deciding whether a due run actually happens — a feature flag, a leader election. |
597
+ | `TaskHook` | What runs before or after a task. |
598
+ | `ScheduleRunRecord`, `ScheduleRunStore` | One recorded run, and where the log is kept. |
599
+ | `RunLogConfig` | How much of that log is retained. |
600
+ | `OutputMailer` | Sending a task's output somewhere when it finishes. |
601
+
528
602
  ## Next steps
529
603
 
530
604
  - [Queue](/docs/queue) — schedules typically dispatch jobs; the worker runs both.
package/docs/session.md CHANGED
@@ -444,6 +444,12 @@ try {
444
444
  The fix is nearly always to store an identifier rather than the object, or to
445
445
  move to a server-side driver where the cookie holds only the session id.
446
446
 
447
+ ## Types
448
+
449
+ `SessionDriver` is the storage contract — implement it to keep sessions somewhere the built-ins
450
+ do not reach. `SessionPayload` is what a driver loads and saves: the id and the data bag.
451
+ `SessionAccessor` is the request-scoped handle `ctx.session` exposes.
452
+
447
453
  ## Next steps
448
454
 
449
455
  - [Middleware](/docs/middleware) — where `SessionMiddleware` and `CsrfMiddleware`
package/docs/social.md CHANGED
@@ -516,6 +516,16 @@ validation errors is exactly the string you forward to `?error=`.
516
516
  | `OAuthUserFetchError` | provider message | User-profile fetch failed. |
517
517
  | `AppleClientSecretError` | Apple credentials message | Apple driver has neither a JWT nor raw key trio. |
518
518
 
519
+ ## Types
520
+
521
+ | Type | What it is |
522
+ | ------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
523
+ | `OAuth2Config` | What a provider entry in `config/services.ts` holds — id, secret, redirect URI. |
524
+ | `AppleOAuth2Config` | Apple's, which needs more: a team id, a key id, and the private key it signs with. |
525
+ | `SocialSession` | What the driver stashes between the redirect out and the callback back. |
526
+ | `GoogleDriver` | Exported so a custom driver can extend it rather than reimplement OIDC. |
527
+ | `FakeSocialDriver` | What `Social.fake()` installs — returns a canned profile instead of exchanging a code. See [Mocking](/docs/testing/mocking). |
528
+
519
529
  ## Next steps
520
530
 
521
531
  - [Authentication](/docs/authentication) — establish the session after a social login.
package/docs/storage.md CHANGED
@@ -626,6 +626,27 @@ Manager and exports from `zerotal/storage`:
626
626
  | `StorageProvider` | `class` | Registers the `storage` binding. |
627
627
  | `DiskNotConfiguredError` | `class` | Thrown when a named disk is missing from config. |
628
628
 
629
+ ### Types
630
+
631
+ The config shapes are exported, so a `config/storage.ts` can be annotated and a helper that
632
+ builds disk options can be typed:
633
+
634
+ | Type | What it is |
635
+ | --------------------- | -------------------------------------------------------------------------- |
636
+ | `DiskConfig` | The discriminated union of every disk config — `local` and `s3` today. |
637
+ | `LocalDiskConfig` | A local disk: `root`, plus `serve` when it is reachable over HTTP. |
638
+ | `S3DiskConfig` | An S3 disk: credentials, `region`, `bucket`, and an optional public `url`. |
639
+ | `DiskServeConfig` | How a disk is exposed over HTTP — the `serve` block `public` uses. |
640
+ | `StorageFilesOptions` | Options for `StorageFilesMiddleware`, which serves a disk's files. |
641
+ | `FakeStoredFile` | What a faked disk records in tests, for asserting on what was written. |
642
+
643
+ `StorageError` is the base every storage failure extends, and `UnsupportedOperationError` is
644
+ what a driver throws for something it genuinely cannot do — a local disk asked for a signed
645
+ URL, for instance — rather than failing quietly and returning nothing.
646
+
647
+ `S3Driver` is exported so a driver for another S3-compatible service can extend it rather than
648
+ reimplement request signing.
649
+
629
650
  ## Next steps
630
651
 
631
652
  - [Validator](/docs/validator) — validate file uploads before storing them.
@@ -109,7 +109,19 @@ outcomes — promote, keep with a new date and the reason, or withdraw.
109
109
 
110
110
  | Package | Now | Reviewed by |
111
111
  | ------------- | -------------- | ----------- |
112
- | `@zerotal/ai` | `experimental` | **1.9.0** |
112
+ | `@zerotal/ai` | `experimental` | **1.11.0** |
113
+
114
+ `@zerotal/ai`'s date moved once, from 1.9.0, and the reason is the same one that made it
115
+ experimental in the first place: **it graduates in the release after its first real users, and
116
+ it has not had them yet.** Promoting on a date rather than on evidence is how a label becomes
117
+ decoration — a `stable` promise is only worth making about an API that something has pushed
118
+ against.
119
+
120
+ The date is a forcing function, not a prediction. It moved once; a second move needs a better
121
+ reason than the first, or the honest answer is to withdraw the package rather than keep
122
+ re-dating it. Its surface triage and the tests for its SSE parser and prompt redaction are worth
123
+ doing meanwhile, and are tracked separately — they improve the package whichever way the review
124
+ goes, and they are what would otherwise turn the deadline into a scramble.
113
125
 
114
126
  `@zerotal/arch` held `beta` with the same date, was reviewed ahead of it, and is
115
127
  `stable` — the release that carried the promotion is the one its