@zerotal/arch 1.9.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api-surface.md +6 -6
- package/docs/ai.md +82 -0
- package/docs/changelog.md +292 -0
- package/docs/deployment.md +52 -4
- package/docs/helpers.md +51 -0
- package/docs/inertia/middleware.md +44 -0
- package/docs/inertia/rendering.md +67 -0
- package/docs/inertia/ssr.md +95 -10
- package/docs/middleware.md +31 -0
- package/docs/migrations.md +10 -0
- package/docs/orm/index.md +15 -6
- package/docs/rate-limiting.md +45 -17
- package/docs/scheduler.md +71 -8
- package/docs/support-policy.md +14 -10
- package/docs/testing/http.md +52 -36
- package/docs/testing/index.md +140 -0
- package/docs/upgrade.md +129 -9
- package/package.json +3 -3
- package/src/install/guidelines.ts +1 -1
- package/src/mcp/stdio.ts +3 -3
- package/src/tools/_probe.ts +2 -2
|
@@ -112,6 +112,73 @@ models with unloaded relations** — eager-load what the page needs (`.with("aut
|
|
|
112
112
|
or map to a plain shape. Shared props (`auth.user`) are already reduced to scalars
|
|
113
113
|
for you; see [Shared Props](/docs/inertia/props).
|
|
114
114
|
|
|
115
|
+
## Persistent layouts
|
|
116
|
+
|
|
117
|
+
A page can name a layout that survives navigation — the shell is not unmounted and
|
|
118
|
+
remounted between visits, so its state, scroll position and any open panel stay put:
|
|
119
|
+
|
|
120
|
+
```tsx fragment
|
|
121
|
+
// resources/js/pages/mail.tsx
|
|
122
|
+
import MailLayout from "../Layouts/MailLayout";
|
|
123
|
+
|
|
124
|
+
export default function Mail({ messages }) {
|
|
125
|
+
return <MessageList messages={messages} />;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
Mail.layout = (page) => <MailLayout>{page}</MailLayout>;
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### The callback is handed the page element, not the page props
|
|
132
|
+
|
|
133
|
+
This is the one thing to get right, because getting it wrong fails in a way nothing
|
|
134
|
+
on the server can see:
|
|
135
|
+
|
|
136
|
+
```tsx fragment
|
|
137
|
+
// WRONG — `page.props` is undefined. Compiles, 200s, blank screen.
|
|
138
|
+
Mail.layout = (page) => <MailLayout search={page.props.search}>{page}</MailLayout>;
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The argument is the rendered page **element**. It has no `props.search`, so this
|
|
142
|
+
throws `Cannot read properties of undefined` on the first paint — in the browser,
|
|
143
|
+
after the response has been sent. The route still answers `200`, the Inertia payload
|
|
144
|
+
is still correct, and a server-side test still passes. The user gets a white page.
|
|
145
|
+
|
|
146
|
+
Read props with `usePage()` inside a layout component instead:
|
|
147
|
+
|
|
148
|
+
```tsx fragment
|
|
149
|
+
// resources/js/pages/mail.tsx
|
|
150
|
+
import { usePage } from "@inertiajs/react";
|
|
151
|
+
import type { SharedProps } from "../types";
|
|
152
|
+
|
|
153
|
+
function MailLayout({ children }) {
|
|
154
|
+
const { props } = usePage<SharedProps & { search?: string }>();
|
|
155
|
+
return <SuiteLayout search={props.search}>{children}</SuiteLayout>;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
Mail.layout = (page) => <MailLayout>{page}</MailLayout>;
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`usePage()` reads the same page object the server sent, from context, and works at
|
|
162
|
+
any depth — so a layout five components down needs nothing threaded to it.
|
|
163
|
+
|
|
164
|
+
> **Why the wrong form typechecks.** `@inertiajs/react` types the callback's argument
|
|
165
|
+
> loosely enough that reaching for `.props` is not a compile error, and a cast to get
|
|
166
|
+
> past a complaint makes it worse. The check that catches it is
|
|
167
|
+
> [rendering the page in a test](/docs/testing#pages-render) — the scaffold ships one,
|
|
168
|
+
> and it is the only thing in a normal suite that builds the component tree at all.
|
|
169
|
+
|
|
170
|
+
### One layout for several pages
|
|
171
|
+
|
|
172
|
+
Assign the same callback, or export it from the layout module and reuse it:
|
|
173
|
+
|
|
174
|
+
```tsx fragment
|
|
175
|
+
// resources/js/Layouts/MailLayout.tsx
|
|
176
|
+
export const withMailLayout = (page: ReactNode) => <MailLayout>{page}</MailLayout>;
|
|
177
|
+
|
|
178
|
+
// resources/js/pages/mail.tsx
|
|
179
|
+
Mail.layout = withMailLayout;
|
|
180
|
+
```
|
|
181
|
+
|
|
115
182
|
## First load vs. navigation
|
|
116
183
|
|
|
117
184
|
`inertia()` branches on the `X-Inertia` request header:
|
package/docs/inertia/ssr.md
CHANGED
|
@@ -75,27 +75,112 @@ export class PostController {
|
|
|
75
75
|
### Requirements
|
|
76
76
|
|
|
77
77
|
- `react-dom/server` ≥ 18 (for `renderToReadableStream`)
|
|
78
|
+
- `@inertiajs/react` — the same adapter the browser entry point uses; the server
|
|
79
|
+
renders through its `<App>` so `<Head>` works (see below)
|
|
78
80
|
- The HTML template must contain `<!-- @inertia -->`
|
|
79
81
|
- The page component must exist under your pages directory (`resources/js/pages/<component>.tsx`)
|
|
80
82
|
|
|
81
|
-
It throws if the template hasn't loaded, or if the component name contains path
|
|
82
|
-
traversal (`..` or a leading `/`).
|
|
83
|
-
|
|
84
83
|
### inertia vs. inertiaStream
|
|
85
84
|
|
|
86
|
-
| Criterion | `inertia()`
|
|
87
|
-
| -------------- |
|
|
88
|
-
| Return type | `Promise<void>`
|
|
89
|
-
| Rendering |
|
|
90
|
-
| Response body | Fully buffered string
|
|
91
|
-
| TTFB |
|
|
92
|
-
|
|
|
85
|
+
| Criterion | `inertia()` | `inertiaStream()` |
|
|
86
|
+
| -------------- | ------------------------ | ---------------------------------- |
|
|
87
|
+
| Return type | `Promise<void>` | `Promise<void>` |
|
|
88
|
+
| Rendering | None — empty root + JSON | Streaming `renderToReadableStream` |
|
|
89
|
+
| Response body | Fully buffered string | Streaming `ReadableStream` |
|
|
90
|
+
| TTFB | Immediate | After the shell is ready |
|
|
91
|
+
| Page `<Head>` | Client only | Collected into the served `<head>` |
|
|
92
|
+
| XHR navigation | JSON (the normal path) | N/A — only the first-page document |
|
|
93
93
|
|
|
94
94
|
For XHR navigations (`X-Inertia: true`), keep using `inertia()` — streaming only
|
|
95
95
|
benefits the initial HTML document load.
|
|
96
96
|
|
|
97
97
|
> **Tip** — Stream the heaviest landing pages and leave everything else on `inertia()`.
|
|
98
98
|
|
|
99
|
+
## Page metadata: `<Head>` on the server
|
|
100
|
+
|
|
101
|
+
Both server-rendered paths — `inertiaStream()` and the `/__ssr` endpoint — collect
|
|
102
|
+
whatever your page's `<Head>` declares and splice it into the template's `<head>`
|
|
103
|
+
before the response goes out. A page writes its metadata once, in the component, and
|
|
104
|
+
gets it in the HTML as well as in the browser:
|
|
105
|
+
|
|
106
|
+
```tsx fragment
|
|
107
|
+
// resources/js/pages/Trips/Show.tsx
|
|
108
|
+
import { Head } from "@inertiajs/react";
|
|
109
|
+
|
|
110
|
+
export default function Show({ trip }) {
|
|
111
|
+
return (
|
|
112
|
+
<>
|
|
113
|
+
<Head>
|
|
114
|
+
<title>{trip.name}</title>
|
|
115
|
+
<meta name="description" content={trip.summary} />
|
|
116
|
+
<meta property="og:title" content={trip.name} />
|
|
117
|
+
<meta property="og:image" content={trip.heroUrl} />
|
|
118
|
+
</Head>
|
|
119
|
+
…
|
|
120
|
+
</>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
An injected tag **replaces** the template's tag of the same identity rather than
|
|
126
|
+
being added after it — `<title>` by being a title, `<meta>` by its `name` or
|
|
127
|
+
`property`. That is not a detail: a document with two `<title>` tags is a document
|
|
128
|
+
with the _first_ one, so an appended title would be present, correct and ignored.
|
|
129
|
+
Anything the template does not already declare is appended before `</head>`.
|
|
130
|
+
|
|
131
|
+
Two things it does not do:
|
|
132
|
+
|
|
133
|
+
- **The title callback is client-side.** `createInertiaApp({ title })` in your
|
|
134
|
+
browser entry point is not visible to the server, so a page rendering
|
|
135
|
+
`<Head><title>Kruger</title></Head>` serves `Kruger` and the browser then shows
|
|
136
|
+
`Kruger — App`. Put the suffix in the `<Head>` itself if the served title matters
|
|
137
|
+
to you, which for a link preview it usually does.
|
|
138
|
+
- **`inertia()` does not render, so it does not collect.** A page returned through
|
|
139
|
+
plain `inertia()` sends the template's `<head>` as written. See
|
|
140
|
+
[What a crawler sees](#what-a-crawler-sees).
|
|
141
|
+
|
|
142
|
+
## What a crawler sees
|
|
143
|
+
|
|
144
|
+
`inertia()` — the default — **does not server-render the component at all.** Its
|
|
145
|
+
response body is the template with an empty root and the page object beside it:
|
|
146
|
+
|
|
147
|
+
```html
|
|
148
|
+
<body>
|
|
149
|
+
<div id="app"></div>
|
|
150
|
+
<script type="application/json" data-page="app">
|
|
151
|
+
{ … }
|
|
152
|
+
</script>
|
|
153
|
+
</body>
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
That is the normal Inertia arrangement and it is the right default: the page is
|
|
157
|
+
built by the client, and every navigation after the first is JSON. But it means the
|
|
158
|
+
served document contains **a title and a JSON blob**, and it is worth knowing which
|
|
159
|
+
readers of your site run JavaScript and which do not:
|
|
160
|
+
|
|
161
|
+
| Reader | Runs JavaScript | Sees your page |
|
|
162
|
+
| ---------------------------------------------------- | --------------------- | ----------------- |
|
|
163
|
+
| A browser | yes | yes |
|
|
164
|
+
| Googlebot, Bingbot | yes, on a second pass | yes, later |
|
|
165
|
+
| WhatsApp, Slack, iMessage, X, Facebook link previews | **no** | title + meta only |
|
|
166
|
+
| `curl`, uptime checks, most RSS and reader tools | **no** | title + meta only |
|
|
167
|
+
|
|
168
|
+
So the link preview a page produces is decided entirely by its `<head>` — which is
|
|
169
|
+
the template's, identically, on every page, unless you do one of these:
|
|
170
|
+
|
|
171
|
+
1. **Switch the page to `inertiaStream()`.** The component is rendered, `<Head>` is
|
|
172
|
+
collected, and the served `<head>` is the page's own. This is the smallest change
|
|
173
|
+
and the one to reach for on pages that get shared.
|
|
174
|
+
2. **Turn on endpoint SSR** (`ssr: true`) for the whole app.
|
|
175
|
+
3. **Set the tags in middleware**, if the metadata is server-side data the component
|
|
176
|
+
does not otherwise need.
|
|
177
|
+
|
|
178
|
+
`curl` is also how most people first check whether a deploy worked. An empty
|
|
179
|
+
`<div id="app">` in that output is not a broken deploy.
|
|
180
|
+
|
|
181
|
+
It throws if the template hasn't loaded, or if the component name contains path
|
|
182
|
+
traversal (`..` or a leading `/`).
|
|
183
|
+
|
|
99
184
|
## Next steps
|
|
100
185
|
|
|
101
186
|
- [Inertia overview](/docs/inertia) — the guide's front page and the rest of the sections.
|
package/docs/middleware.md
CHANGED
|
@@ -206,6 +206,37 @@ The package ships several middleware you can drop straight into `app.use([...])`
|
|
|
206
206
|
or a route's middleware array. Each extends `BaseMiddleware`, so `.with({ … })`
|
|
207
207
|
bakes options into a zero-argument class.
|
|
208
208
|
|
|
209
|
+
### Names the framework already occupies
|
|
210
|
+
|
|
211
|
+
Middleware live in a flat namespace: your `app/middleware/` classes are discovered by
|
|
212
|
+
class name, and so are the ones a package exports. Naming one of yours after one of
|
|
213
|
+
these is not caught as a conflict — it surfaces later as a type error somewhere that
|
|
214
|
+
does not mention either file, which is a confusing way to learn that
|
|
215
|
+
`TwoFactorMiddleware` was taken.
|
|
216
|
+
|
|
217
|
+
The full list, so you can check before you name:
|
|
218
|
+
|
|
219
|
+
| Middleware | Package |
|
|
220
|
+
| -------------------------------------------------------------------------------------- | -------------------------------- |
|
|
221
|
+
| `CorsMiddleware`, `SecureHeadersMiddleware`, `ThrottleMiddleware`, `WebhookMiddleware` | `@zerotal/core` |
|
|
222
|
+
| `AuthMiddleware`, `GuestMiddleware`, `PersistUserMiddleware`, `RememberMeMiddleware` | `@zerotal/auth` |
|
|
223
|
+
| `BasicAuthMiddleware`, `BearerTokenMiddleware`, `JwtGuardMiddleware` | `@zerotal/auth` |
|
|
224
|
+
| `RequireRoleMiddleware`, `RequirePermissionMiddleware`, `TwoFactorMiddleware` | `@zerotal/auth` |
|
|
225
|
+
| `ValidateSignatureMiddleware` | `@zerotal/auth` |
|
|
226
|
+
| `SessionMiddleware`, `CsrfMiddleware`, `AuthSessionMiddleware` | `@zerotal/session` |
|
|
227
|
+
| `InertiaMiddleware`, `PrecognitionMiddleware` | `@zerotal/inertia` |
|
|
228
|
+
| `AdminGuardMiddleware`, `AdminAbilityMiddleware` | `@zerotal/admin` |
|
|
229
|
+
| `MonitorAuthMiddleware`, `MonitorPayloadMiddleware` | `@zerotal/monitor` |
|
|
230
|
+
| `IdempotencyMiddleware` | `@zerotal/cache` |
|
|
231
|
+
| `LocaleMiddleware` | `@zerotal/i18n` |
|
|
232
|
+
| `EnsureTenancyMiddleware` | `@zerotal/tenancy` |
|
|
233
|
+
| `TelemetryMiddleware` | `@zerotal/telemetry` |
|
|
234
|
+
| `BaseMiddleware` | `@zerotal/core` (the base class) |
|
|
235
|
+
|
|
236
|
+
If yours does something different from the framework's, say so in the name rather
|
|
237
|
+
than shadowing it — `RequireTwoFactorMiddleware` for "fence the console until staff
|
|
238
|
+
have enrolled" reads better than `TwoFactorMiddleware` anyway, and cannot collide.
|
|
239
|
+
|
|
209
240
|
### CorsMiddleware
|
|
210
241
|
|
|
211
242
|
```ts fragment
|
package/docs/migrations.md
CHANGED
|
@@ -397,6 +397,16 @@ The `onDelete` / `onUpdate` actions are `"CASCADE"`, `"SET NULL"`, `"RESTRICT"`,
|
|
|
397
397
|
`"NO ACTION"`. Shorthands `cascadeOnDelete()`, `nullOnDelete()`, and `restrictOnDelete()`
|
|
398
398
|
read more fluently.
|
|
399
399
|
|
|
400
|
+
> **SQLite only enforces these when asked**, and it is the only supported dialect that
|
|
401
|
+
> behaves that way — `database.sqlite.foreignKeys` defaults to `true` and sets
|
|
402
|
+
> `PRAGMA foreign_keys = ON` on every connection. Turn it off and the declarations
|
|
403
|
+
> above become comments: deleting a parent leaves its children, silently, and every
|
|
404
|
+
> child has to be removed by hand in the right order. An app's data-erasure path
|
|
405
|
+
> missed three tables that way, two of them holding uploaded files.
|
|
406
|
+
>
|
|
407
|
+
> On a database that ran without enforcement, `bun zt db:check-foreign-keys` lists any
|
|
408
|
+
> rows that would now be rejected. Postgres and MySQL always enforce.
|
|
409
|
+
|
|
400
410
|
## Soft deletes
|
|
401
411
|
|
|
402
412
|
```typescript fragment
|
package/docs/orm/index.md
CHANGED
|
@@ -202,12 +202,21 @@ import { column } from "@zerotal/orm";
|
|
|
202
202
|
|
|
203
203
|
Shorthands map to: `string`, `text`, `integer`, `number`, `float`, `boolean`, `datetime`, `date`, `json`, `array`, `encrypted`, `encrypted:json`. See [Casts & Mutators](/docs/orm/casts) for the full cast reference.
|
|
204
204
|
|
|
205
|
-
|
|
206
|
-
`
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
205
|
+
**`type` takes either vocabulary.** The _storage_ types are `string`, `text`,
|
|
206
|
+
`number`, `boolean`, `datetime` and `json` — what schema generation emits. The
|
|
207
|
+
shorthands that look like types (`integer`, `float`, `date`, `encrypted`) are
|
|
208
|
+
type-and-cast pairs, and writing one as a `type` resolves it the same way the string
|
|
209
|
+
form does:
|
|
210
|
+
|
|
211
|
+
```typescript fragment
|
|
212
|
+
// in a model class body
|
|
213
|
+
@column({ type: "integer", default: 0 }) retries!: number; // → { type: "number", cast: "integer" }
|
|
214
|
+
@column({ type: "encrypted", nullable: true }) idNumber?: string; // → { type: "text", cast: "encrypted" }
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
`{ type: "integer" }` used to be an error while `@column("integer")` compiled, so the
|
|
218
|
+
vocabulary halved exactly when a column needed `default`, `nullable` or `unique` —
|
|
219
|
+
which is most real columns. An explicit `cast` alongside a shorthand still wins.
|
|
211
220
|
|
|
212
221
|
`string` is a bounded VARCHAR and `text` is the unbounded TEXT type — a distinction that matters on Postgres and MySQL, where a long body in a `VARCHAR(255)` is an error rather than a slow column.
|
|
213
222
|
|
package/docs/rate-limiting.md
CHANGED
|
@@ -70,6 +70,14 @@ ThrottleMiddleware.with({
|
|
|
70
70
|
which entry is not attacker-controlled. Left `undefined` (or `0`), the unspoofable socket
|
|
71
71
|
address is used.
|
|
72
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
|
+
|
|
73
81
|
> **Danger** — That default is right, and it is the wrong answer the moment you deploy
|
|
74
82
|
> behind a proxy. The socket address is then the _proxy's_ — `127.0.0.1` for every visitor
|
|
75
83
|
> — so everyone shares one bucket per form and the limiter inverts into the thing it was
|
|
@@ -117,8 +125,9 @@ import { RateLimiter } from "zerotal";
|
|
|
117
125
|
// 1000 req/hour per authenticated user (falls back to IP when unauthenticated)
|
|
118
126
|
RateLimiter.for("api").limit(1000).every(3600).byUser().register();
|
|
119
127
|
|
|
120
|
-
// 5 login attempts per minute, per IP
|
|
121
|
-
|
|
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();
|
|
122
131
|
|
|
123
132
|
// 500 req/min keyed by an API-key header (unknown key → per IP)
|
|
124
133
|
RateLimiter.for("partner").limit(500).every(60).byApiKey("x-api-key").register();
|
|
@@ -140,12 +149,30 @@ RateLimiter.for("upload")
|
|
|
140
149
|
Each `.by*()` call sets how requests are bucketed. The default (no `.by*()` call)
|
|
141
150
|
is the client IP.
|
|
142
151
|
|
|
143
|
-
| Method | Keys on
|
|
144
|
-
| -------------------- |
|
|
145
|
-
| `.byUser()` | `ctx.user.id`
|
|
146
|
-
| `.byApiKey(header?)` | `x-api-key` header (or a custom header)
|
|
147
|
-
| `.byIp()` |
|
|
148
|
-
| `.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.
|
|
149
176
|
|
|
150
177
|
### Applying a named limiter
|
|
151
178
|
|
|
@@ -263,15 +290,16 @@ await app.post("/login", { email: "a@b.c" }, { "X-Forwarded-For": "10.0.0.7" });
|
|
|
263
290
|
|
|
264
291
|
### `LimiterDefinition` (fluent)
|
|
265
292
|
|
|
266
|
-
| Method
|
|
267
|
-
|
|
|
268
|
-
| `limit`
|
|
269
|
-
| `every`
|
|
270
|
-
| `byUser`
|
|
271
|
-
| `byApiKey`
|
|
272
|
-
| `byIp`
|
|
273
|
-
| `by`
|
|
274
|
-
| `
|
|
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. |
|
|
275
303
|
|
|
276
304
|
### `ThrottleMiddleware`
|
|
277
305
|
|
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", "
|
|
64
|
+
timezone: env("APP_TIMEZONE", "Africa/Johannesburg"),
|
|
65
65
|
});
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
-
| Field | Required | Default
|
|
69
|
-
| ---------- | -------- |
|
|
70
|
-
| `timezone` | no |
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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 |
|
package/docs/support-policy.md
CHANGED
|
@@ -64,17 +64,21 @@ All `@zerotal/*` packages and `create-zerotal` share **one version line and
|
|
|
64
64
|
publish lockstep** — a release publishes every package at the same version, in
|
|
65
65
|
dependency order, from CI. Never mix versions across packages.
|
|
66
66
|
|
|
67
|
-
- **
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
67
|
+
- **What the numbers mean:** a **patch** is anything that does not break —
|
|
68
|
+
a fix, and a feature too. A **minor** carries a breaking change. A **major** is
|
|
69
|
+
an annual consolidation, cut each July. The
|
|
70
|
+
[Upgrade Guide](/docs/upgrade#versioning) explains why the framework is versioned
|
|
71
|
+
this way and describes the upgrade procedure; the
|
|
72
|
+
[Release Notes](/docs/changelog) list what changed.
|
|
73
|
+
- **What that costs you:** a caret range crosses a minor, so a project on
|
|
74
|
+
`^1.10.0` takes 1.11.0 and its breaking change without being asked. Pin with a
|
|
75
|
+
tilde if you would rather cross a minor deliberately.
|
|
76
|
+
- **A break is never silent.** Every one is called out in the release notes as
|
|
77
|
+
**BREAKING**, with the reason and the migration steps, and the version gets its
|
|
78
|
+
own section in the Upgrade Guide. Four have shipped so far — the
|
|
74
79
|
`ComponentWith` / `BaseModelWith` removal in 1.3.0, Flow's `socket:` listener
|
|
75
|
-
prefix in 1.7.2,
|
|
76
|
-
|
|
77
|
-
makes the cost of a break real.
|
|
80
|
+
prefix in 1.7.2, the removal of Flow's `this.title(…)` in 1.7.3, and SQLite
|
|
81
|
+
foreign-key enforcement in 1.11.0.
|
|
78
82
|
- **Provenance:** packages are published with npm provenance, so you can verify
|
|
79
83
|
a tarball was built by this repository's release workflow rather than someone's
|
|
80
84
|
laptop.
|