@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.
@@ -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:
@@ -75,27 +75,112 @@ export class PostController {
75
75
  ### Requirements
76
76
 
77
77
  - `react-dom/server` ≥ 18 (for `renderToReadableStream`)
78
+ - `@inertiajs/react` — the same adapter the browser entry point uses; the server
79
+ renders through its `<App>` so `<Head>` works (see below)
78
80
  - The HTML template must contain `<!-- @inertia -->`
79
81
  - The page component must exist under your pages directory (`resources/js/pages/<component>.tsx`)
80
82
 
81
- It throws if the template hasn't loaded, or if the component name contains path
82
- traversal (`..` or a leading `/`).
83
-
84
83
  ### inertia vs. inertiaStream
85
84
 
86
- | Criterion | `inertia()` | `inertiaStream()` |
87
- | -------------- | ---------------------- | ---------------------------------- |
88
- | Return type | `Promise<void>` | `Promise<void>` |
89
- | Rendering | Buffered HTML string | Streaming `renderToReadableStream` |
90
- | Response body | Fully buffered string | Streaming `ReadableStream` |
91
- | TTFB | After full render | After the prefix is flushed |
92
- | XHR navigation | JSON (the normal path) | N/A only the first-page document |
85
+ | Criterion | `inertia()` | `inertiaStream()` |
86
+ | -------------- | ------------------------ | ---------------------------------- |
87
+ | Return type | `Promise<void>` | `Promise<void>` |
88
+ | Rendering | None empty root + JSON | Streaming `renderToReadableStream` |
89
+ | Response body | Fully buffered string | Streaming `ReadableStream` |
90
+ | TTFB | Immediate | After the shell is ready |
91
+ | Page `<Head>` | Client only | Collected into the served `<head>` |
92
+ | XHR navigation | JSON (the normal path) | N/A — only the first-page document |
93
93
 
94
94
  For XHR navigations (`X-Inertia: true`), keep using `inertia()` — streaming only
95
95
  benefits the initial HTML document load.
96
96
 
97
97
  > **Tip** — Stream the heaviest landing pages and leave everything else on `inertia()`.
98
98
 
99
+ ## Page metadata: `<Head>` on the server
100
+
101
+ Both server-rendered paths — `inertiaStream()` and the `/__ssr` endpoint — collect
102
+ whatever your page's `<Head>` declares and splice it into the template's `<head>`
103
+ before the response goes out. A page writes its metadata once, in the component, and
104
+ gets it in the HTML as well as in the browser:
105
+
106
+ ```tsx fragment
107
+ // resources/js/pages/Trips/Show.tsx
108
+ import { Head } from "@inertiajs/react";
109
+
110
+ export default function Show({ trip }) {
111
+ return (
112
+ <>
113
+ <Head>
114
+ <title>{trip.name}</title>
115
+ <meta name="description" content={trip.summary} />
116
+ <meta property="og:title" content={trip.name} />
117
+ <meta property="og:image" content={trip.heroUrl} />
118
+ </Head>
119
+
120
+ </>
121
+ );
122
+ }
123
+ ```
124
+
125
+ An injected tag **replaces** the template's tag of the same identity rather than
126
+ being added after it — `<title>` by being a title, `<meta>` by its `name` or
127
+ `property`. That is not a detail: a document with two `<title>` tags is a document
128
+ with the _first_ one, so an appended title would be present, correct and ignored.
129
+ Anything the template does not already declare is appended before `</head>`.
130
+
131
+ Two things it does not do:
132
+
133
+ - **The title callback is client-side.** `createInertiaApp({ title })` in your
134
+ browser entry point is not visible to the server, so a page rendering
135
+ `<Head><title>Kruger</title></Head>` serves `Kruger` and the browser then shows
136
+ `Kruger — App`. Put the suffix in the `<Head>` itself if the served title matters
137
+ to you, which for a link preview it usually does.
138
+ - **`inertia()` does not render, so it does not collect.** A page returned through
139
+ plain `inertia()` sends the template's `<head>` as written. See
140
+ [What a crawler sees](#what-a-crawler-sees).
141
+
142
+ ## What a crawler sees
143
+
144
+ `inertia()` — the default — **does not server-render the component at all.** Its
145
+ response body is the template with an empty root and the page object beside it:
146
+
147
+ ```html
148
+ <body>
149
+ <div id="app"></div>
150
+ <script type="application/json" data-page="app">
151
+ { … }
152
+ </script>
153
+ </body>
154
+ ```
155
+
156
+ That is the normal Inertia arrangement and it is the right default: the page is
157
+ built by the client, and every navigation after the first is JSON. But it means the
158
+ served document contains **a title and a JSON blob**, and it is worth knowing which
159
+ readers of your site run JavaScript and which do not:
160
+
161
+ | Reader | Runs JavaScript | Sees your page |
162
+ | ---------------------------------------------------- | --------------------- | ----------------- |
163
+ | A browser | yes | yes |
164
+ | Googlebot, Bingbot | yes, on a second pass | yes, later |
165
+ | WhatsApp, Slack, iMessage, X, Facebook link previews | **no** | title + meta only |
166
+ | `curl`, uptime checks, most RSS and reader tools | **no** | title + meta only |
167
+
168
+ So the link preview a page produces is decided entirely by its `<head>` — which is
169
+ the template's, identically, on every page, unless you do one of these:
170
+
171
+ 1. **Switch the page to `inertiaStream()`.** The component is rendered, `<Head>` is
172
+ collected, and the served `<head>` is the page's own. This is the smallest change
173
+ and the one to reach for on pages that get shared.
174
+ 2. **Turn on endpoint SSR** (`ssr: true`) for the whole app.
175
+ 3. **Set the tags in middleware**, if the metadata is server-side data the component
176
+ does not otherwise need.
177
+
178
+ `curl` is also how most people first check whether a deploy worked. An empty
179
+ `<div id="app">` in that output is not a broken deploy.
180
+
181
+ It throws if the template hasn't loaded, or if the component name contains path
182
+ traversal (`..` or a leading `/`).
183
+
99
184
  ## Next steps
100
185
 
101
186
  - [Inertia overview](/docs/inertia) — the guide's front page and the rest of the sections.
@@ -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
@@ -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
- A shorthand is not the same as `type`. `type` is only the storage type —
206
- `string`, `text`, `number`, `boolean`, `datetime`, `json` — so `{ type: "integer" }`
207
- and `{ type: "encrypted" }` are both errors. The shorthands that look like types
208
- (`integer`, `float`, `encrypted`) are type-and-cast pairs: `@column("integer")` is
209
- `{ type: "number", cast: "integer" }`, and `@column("encrypted")` is
210
- `{ type: "text", cast: "encrypted" }`.
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
 
@@ -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
- 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();
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 | Falls back to |
144
- | -------------------- | ------------------------------------------- | ------------------------ |
145
- | `.byUser()` | `ctx.user.id` | IP when unauthenticated |
146
- | `.byApiKey(header?)` | `x-api-key` header (or a custom header) | IP when header is absent |
147
- | `.byIp()` | Socket IP `X-Forwarded-For` → `X-Real-IP` | `'unknown'` |
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 | Signature | Description |
267
- | ---------- | -------------------------------------------- | ---------------------------------------------------------- |
268
- | `limit` | `limit(max: number): this` | Maximum requests in the window (default `60`). |
269
- | `every` | `every(seconds: number): this` | Window duration in seconds (default `60`). |
270
- | `byUser` | `byUser(): this` | Key by `ctx.user.id`; IP when unauthenticated. |
271
- | `byApiKey` | `byApiKey(header?: string): this` | Key by header value (default `x-api-key`); IP when absent. |
272
- | `byIp` | `byIp(): this` | Key by client IP (the explicit default). |
273
- | `by` | `by(fn: (ctx: HttpContext) => string): this` | Key by your own resolver. |
274
- | `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. |
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", "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 |
@@ -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
- - **Semantic versioning:** patch for fixes, minor for compatible features, major
68
- for breaking changes. The [Upgrade Guide](/docs/upgrade) describes the upgrade
69
- procedure; the [Release Notes](/docs/changelog) list what changed.
70
- - **One exception, while the 1.x line is young:** a breaking change may land in a
71
- minor or a patch when leaving it in place would cost more than the migration
72
- does. It is called out in the release notes as **BREAKING**, with the reason and
73
- the migration steps, and it is never silent. Three have shipped so far the
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, and the removal of Flow's `this.title(…)` in 1.7.3. This carve-out is a consequence of the project's age, not a
76
- standing policy; it will be withdrawn, with a version named here, once adoption
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.