@zerotal/arch 1.8.1 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/docs/admin/actions.md +15 -0
  2. package/docs/admin/auth.md +10 -0
  3. package/docs/admin/dashboard.md +12 -0
  4. package/docs/admin/extending-ui.md +14 -0
  5. package/docs/admin/forms.md +15 -0
  6. package/docs/admin/operations.md +12 -0
  7. package/docs/admin/resources.md +6 -0
  8. package/docs/admin/tables.md +21 -0
  9. package/docs/audit.md +5 -0
  10. package/docs/authentication.md +110 -1
  11. package/docs/broadcasting/references.md +17 -0
  12. package/docs/cache.md +5 -0
  13. package/docs/carbon.md +5 -0
  14. package/docs/changelog.md +125 -0
  15. package/docs/client/index.md +17 -0
  16. package/docs/commands.md +6 -0
  17. package/docs/components.md +73 -0
  18. package/docs/config-system.md +54 -0
  19. package/docs/cookies.md +6 -0
  20. package/docs/deployment.md +103 -13
  21. package/docs/devtools.md +5 -0
  22. package/docs/email-verification.md +26 -1
  23. package/docs/encryption.md +21 -0
  24. package/docs/errors.md +2 -0
  25. package/docs/flow/components.md +54 -0
  26. package/docs/flow/forms.md +57 -0
  27. package/docs/flow/references.md +14 -0
  28. package/docs/getting-started.md +38 -0
  29. package/docs/health.md +19 -0
  30. package/docs/helpers.md +99 -0
  31. package/docs/i18n.md +5 -0
  32. package/docs/inertia/props.md +70 -0
  33. package/docs/lock.md +15 -0
  34. package/docs/logger.md +38 -0
  35. package/docs/migrations.md +47 -0
  36. package/docs/monitor.md +59 -0
  37. package/docs/notifications.md +11 -0
  38. package/docs/orm/casts.md +6 -0
  39. package/docs/orm/lifecycle.md +18 -0
  40. package/docs/orm/queries.md +10 -0
  41. package/docs/orm/relationships.md +30 -0
  42. package/docs/queue.md +10 -0
  43. package/docs/rate-limiting.md +39 -4
  44. package/docs/responses.md +23 -0
  45. package/docs/routing.md +16 -0
  46. package/docs/scheduler.md +11 -0
  47. package/docs/session.md +6 -0
  48. package/docs/social.md +10 -0
  49. package/docs/storage.md +21 -0
  50. package/docs/support-policy.md +13 -1
  51. package/docs/telemetry.md +8 -0
  52. package/docs/tenancy.md +6 -0
  53. package/docs/testing/index.md +6 -0
  54. package/docs/validator.md +9 -0
  55. package/docs/view.md +6 -0
  56. package/package.json +3 -3
@@ -42,6 +42,45 @@ const apiKey = env("API_KEY"); // no fallback → string | undefined
42
42
 
43
43
  > **Note** — When you need a value to be present, use `requireEnv("APP_KEY")` instead — it throws a `ConfigError` at boot if the variable is unset, rather than returning `undefined`.
44
44
 
45
+ ### Declaring the whole environment — `EnvSchema`
46
+
47
+ `env()` is per-call and forgiving: an unset variable is `undefined` and you find out where it is
48
+ used. `@zerotal/core/env` is the other end — declare every variable the app reads, once, and the
49
+ boot either produces a fully typed frozen object or fails with every problem listed at the same
50
+ time:
51
+
52
+ ```typescript fragment
53
+ // env.ts
54
+ import { EnvSchema, t } from "@zerotal/core/env";
55
+
56
+ export const env = EnvSchema.define({
57
+ APP_KEY: t.string().min(32),
58
+ PORT: t.port().default(3000),
59
+ DATABASE_URL: t.string(),
60
+ LOG_LEVEL: t.enum(["debug", "info", "warn", "error"]).default("info"),
61
+ SENTRY_DSN: t.url().optional(),
62
+ });
63
+
64
+ env.PORT; // number — never undefined, because it has a default
65
+ env.LOG_LEVEL; // "debug" | "info" | "warn" | "error", narrowed to the literals
66
+ ```
67
+
68
+ **It reports every failure at once.** A schema with three missing variables fails the boot
69
+ naming all three, rather than one per restart — which is the difference between one fix and
70
+ three round trips through a deploy. The failure is an `EnvSchemaError` carrying an
71
+ `EnvFieldError` per field.
72
+
73
+ | Type | What it is |
74
+ | ---------------- | --------------------------------------------------------------------------- |
75
+ | `EnvSchemaError` | The boot failure, listing every field that did not validate. |
76
+ | `EnvFieldError` | One field's problem: which variable, and what was wrong with it. |
77
+ | `FieldType` | The builders `t` offers — string, number, boolean, port, url, enum. |
78
+ | `EnvOutput<S>` | The typed object a schema produces. `typeof env` where you need to pass it. |
79
+ | `InferDef<D>` | The type one field definition resolves to. |
80
+
81
+ Use `env()` for a value read in one place and `EnvSchema` for the set an app cannot start
82
+ without. They coexist; the schema is not a replacement for the helper.
83
+
45
84
  ## Config files
46
85
 
47
86
  Config files live in `config/`. Each file exports a typed object via a package helper:
@@ -250,6 +289,21 @@ if (config("app.env") === "production") {
250
289
  | `ConfigLoader.get` | `get(key: string, fallback?): value` | Dot-path read against the loaded map. |
251
290
  | `ConfigLoader.validate` | `validate(): this` | Run each file's optional `validate(config)` export, throwing on failure. |
252
291
 
292
+ ## Types
293
+
294
+ | Type | What it is |
295
+ | ------------------- | -------------------------------------------------------------------------------------- |
296
+ | `ConfigValidator` | What `registerConfigValidator` takes — a function handed the config, reporting issues. |
297
+ | `ConfigIssue` | One finding: its namespace, message, and level. |
298
+ | `ConfigIssueLevel` | Whether an issue refuses a production boot or is only worth reading. |
299
+ | `ConventionsConfig` | The `conventions` namespace — where the framework looks for models, jobs and the rest. |
300
+ | `AppTlsConfig` | TLS settings under `app.tls`. |
301
+ | `AssetLoaderKind` | How an asset is loaded by the build — the `loader` values `assets.loaders` accepts. |
302
+
303
+ A validator reporting a **fatal** issue refuses a production-like boot rather than warning. That
304
+ is the whole point of the level: an app that boots with a broken configuration serves wrong
305
+ answers rather than failing, and the failure is the cheaper outcome.
306
+
253
307
  ## Next steps
254
308
 
255
309
  - [Conventions](/docs/conventions) — the auto-discovery settings under the `conventions` key.
package/docs/cookies.md CHANGED
@@ -93,6 +93,12 @@ You rarely set cookies by hand — two parts of the framework manage their own:
93
93
  (non-`HttpOnly`) cookie after every request so Axios/Inertia can echo it back as
94
94
  the `X-XSRF-TOKEN` header.
95
95
 
96
+ ## Types
97
+
98
+ `CookieOptions` is what every cookie-setting call accepts — `maxAge`, `path`, `domain`,
99
+ `httpOnly`, `secure`, `sameSite`. It is exported so a helper that sets a cookie in more than one
100
+ place can take the same shape.
101
+
96
102
  ## Next steps
97
103
 
98
104
  - [Session](/docs/session) — signed, `HttpOnly` cookie-backed state (the usual choice).
@@ -63,18 +63,45 @@ the wrong database.
63
63
 
64
64
  Each entry is a `DeployTarget`:
65
65
 
66
- | Field | Meaning |
67
- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
68
- | `url` | The public URL in this environment. What `--probe` handshakes against when given no URL of its own. |
69
- | `steps` | Override the release steps. Defaults to `DEFAULT_DEPLOY_STEPS` — `assets:build`, `inertia:build`, `migrate`. Each names a `zt` command, and one that is not registered is skipped, so an app without Inertia simply has no Inertia step. |
66
+ | Field | Meaning |
67
+ | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
68
+ | `url` | The public URL in this environment. What `--probe` handshakes against when given no URL of its own. |
69
+ | `steps` | Override the release steps. Defaults to `DEFAULT_DEPLOY_STEPS` — `assets:build`, `inertia:build`, `migrate`. Each names a `zt` command, and one that is not registered is skipped, so an app without Inertia simply has no Inertia step. |
70
+ | `preflight` | Your own commands, run in the preflight phase — after the config validators and `doctor`, before anything is built or migrated. A non-zero exit refuses the release. Defaults to `release:check` when the app registers a command by that name. |
70
71
 
71
72
  Omit the file entirely and you get `DEFAULT_DEPLOY_TARGETS`: `production` and
72
73
  `staging`, both with the default steps.
73
74
 
74
- The defaults build and migrate; they do not check anything you wrote. A preflight command
75
- of your own — `release:check`, a smoke test — has to be named in `steps` to run, and
76
- nothing prompts you to add it, so a command written precisely to guard a release can sit
77
- there never running. Name every step you want, in the order you want them:
75
+ ### Your own release gate
76
+
77
+ The framework's preflight knows the things a framework can know: that this really is the
78
+ environment you think it is, that the config validators pass, that `doctor` is happy. It
79
+ cannot know that this workspace has no cancellation policy, that mail is still wired to the
80
+ `log` driver so nothing is ever sent, or that the owner account is still on the password
81
+ `admin:create` issued it. Those refusals are yours.
82
+
83
+ Write them as a command and name it `release:check` (exported as
84
+ `CONVENTIONAL_PREFLIGHT_COMMAND`). The pipeline finds it by name — nothing to wire up:
85
+
86
+ ```ts
87
+ // app/commands/ReleaseCheckCommand.ts
88
+ import { Command } from "zerotal";
89
+
90
+ export class ReleaseCheckCommand extends Command {
91
+ static commandName = "release:check";
92
+ static description = "Refuse a release this app is not ready for";
93
+ static needsApp = true;
94
+
95
+ async run(): Promise<void> {
96
+ const problems: string[] = [];
97
+ if (Bun.env["APP_KEY"] === "base64:CHANGE_ME") problems.push("APP_KEY is the example one.");
98
+ if (problems.length > 0) throw new Error(problems.join(" "));
99
+ }
100
+ }
101
+ ```
102
+
103
+ A throw, or any non-zero exit, stops the release before `assets:build` has run. To run
104
+ something else — or more than one thing — name them:
78
105
 
79
106
  ```ts
80
107
  // config/deploy.ts
@@ -82,14 +109,16 @@ export default {
82
109
  targets: {
83
110
  production: {
84
111
  url: "https://example.com",
85
- steps: ["release:check", "assets:build", "inertia:build", "migrate"],
112
+ preflight: ["release:check", "smoke:mail"],
86
113
  },
87
114
  },
88
115
  };
89
116
  ```
90
117
 
91
- Put a check first. A step that fails stops the release, and a check that runs after the
92
- migration has already run has missed its moment.
118
+ A name in `preflight` that is not a registered command **fails the deploy** rather than being
119
+ skipped. That is the opposite of how `steps` treats an absent command, and deliberately so: a
120
+ missing `inertia:build` means the app has no Inertia, while a missing gate means the gate is
121
+ not running — which is the state this exists to prevent. A gate nothing calls is a comment.
93
122
 
94
123
  Two things worth adding while you are there: `assets:build` and `inertia:build` accept
95
124
  `--clean`, which removes anything in the output directory the build did not write — see
@@ -180,6 +209,54 @@ bun zt migrate
180
209
  Auto-`synchronize` is **hard-off in production** — generate and commit
181
210
  [migrations](/docs/migrations) during development and run them on deploy.
182
211
 
212
+ ## Back up the database
213
+
214
+ On SQLite the database is one file, which makes `cp` look like a backup. It is not one. A
215
+ live SQLite database has pages in flight; copying the file while the server is serving can
216
+ capture a half-written page, and the result is a file that sits in your retention directory
217
+ for months and turns out to be corrupt on the one morning you open it.
218
+
219
+ `zt db:backup` uses SQLite's own `VACUUM INTO`, which takes a read lock and writes a
220
+ complete database while the server keeps serving. It needs no `sqlite3` binary on the box:
221
+
222
+ ```bash
223
+ # in your project root
224
+ bun zt db:backup --dir=/var/backups/app --keep=30 --require-rows=bookings,invoices
225
+ ```
226
+
227
+ | Flag | What it does |
228
+ | ---------------- | ---------------------------------------------------------------------------- |
229
+ | `--dir` | Where snapshots go. Default `storage/backups`. |
230
+ | `--keep` | How many to keep, newest first. `0` keeps every one. Default `14`. |
231
+ | `--require-rows` | Tables that must not be empty in the snapshot. **Set this.** |
232
+ | `--rehearse` | Also perform the restore — copy the snapshot, open the copy, check it there. |
233
+
234
+ Every snapshot is opened and integrity-checked the moment it is written, and **every failure
235
+ path exits non-zero**. That is what makes it safe to run from a timer: a bad night leaves a
236
+ failed unit somebody can see, rather than a green one and no file.
237
+
238
+ `--require-rows` is the flag that turns "a file was written" into "the file has the business
239
+ in it". An empty `bookings` table in a snapshot of a live system is not a small discrepancy,
240
+ and it is invisible in a byte count.
241
+
242
+ Run `--rehearse` on a schedule of its own — weekly is plenty. A backup nobody has restored is
243
+ a hope, and the restore is the operation you will be doing at 3am.
244
+
245
+ ```ini
246
+ # /etc/systemd/system/app-backup.service
247
+ [Service]
248
+ Type=oneshot
249
+ WorkingDirectory=/srv/app
250
+ Environment=APP_ENV=production
251
+ ExecStart=/srv/app/node_modules/.bin/bun zt db:backup --keep=30 --require-rows=bookings
252
+ ```
253
+
254
+ Pair it with a `.timer`, and let the failed unit be your alert. Retention is handled by
255
+ `--keep`; anything the command did not write is never touched.
256
+
257
+ On PostgreSQL or MySQL the command refuses and points at `pg_dump` / `mysqldump`, which is
258
+ where that job belongs.
259
+
183
260
  ## Build assets
184
261
 
185
262
  Build the frontend bundle as a release step, so `public/` holds compiled output before
@@ -350,6 +427,11 @@ The symptom is a legitimate visitor getting a 429 they did not earn.
350
427
  Count the proxies you actually run. Setting `trustedProxies: 3` with one proxy in front
351
428
  reads an entry the client supplied.
352
429
 
430
+ `zt doctor` warns about this: a production-like deployment with a registered throttle and no
431
+ `trustedProxies` is reported as almost certainly wrong. It is a warning rather than a failure
432
+ because the framework cannot see your deployment — an app served directly, with nothing in
433
+ front of it, is correctly configured exactly as it stands.
434
+
353
435
  ### Never gate the transport path
354
436
 
355
437
  **Browsers do not attach basic-auth credentials to a WebSocket handshake.** An HTTP auth
@@ -513,8 +595,9 @@ Run the worker as a **second** container/service from the same image with the co
513
595
  `bun zt worker`.
514
596
 
515
597
  On a server with no Node installed, `bun install` can fail on a transitive package whose
516
- `postinstall` shells out to `node`. `--ignore-scripts` resolves it, but check what you are
517
- skipping first:
598
+ `postinstall` shells out to `node`. The `bun` npm package is the usual one — its
599
+ `postinstall` downloads a Bun binary the box already has, and there is no `node` to run the
600
+ script with. `--ignore-scripts` resolves it, but check what you are skipping first:
518
601
 
519
602
  ```bash
520
603
  # every package with an install script, before you skip them all
@@ -523,6 +606,13 @@ for p in node_modules/*/package.json node_modules/@*/*/package.json; do
523
606
  done
524
607
  ```
525
608
 
609
+ ### `startZerotal` options
610
+
611
+ `StartZerotalOptions` is what `zt.ts` may pass — currently `configDir`, for an app whose config
612
+ does not live at `./config`. `isDevSurfaceAllowed(env)` is the check every dev-only surface
613
+ gates on, exported so an app's own dev tooling can gate the same way; it **fails closed**, so an
614
+ unset `APP_ENV` does not qualify.
615
+
526
616
  ## Next steps
527
617
 
528
618
  - [Configuration](/docs/config-system) — environment variables and config files.
package/docs/devtools.md CHANGED
@@ -1004,6 +1004,11 @@ meaningfully different from `{}` — a node can be both a branch and a leaf.
1004
1004
  unfiltered list plus whether it heads a group (`groupKey`, `groupSize`) or is a
1005
1005
  folded follow-up (`child`).
1006
1006
 
1007
+ ## Types
1008
+
1009
+ `TraceStoreOptions` sets how many traces are kept and for how long.
1010
+ `DevtoolsClientOptions` configures the browser side.
1011
+
1007
1012
  ## Next steps
1008
1013
 
1009
1014
  - [Logger](/docs/logger) — structured logging that surfaces in the Logs tab.
@@ -49,12 +49,29 @@ register ──▶ Url.sign(/auth/verify?id&email, ttl) ──▶ email link
49
49
 
50
50
  ## Migration
51
51
 
52
- Add a nullable `email_verified_at` column to your users table:
52
+ Whether you need one **depends on what your schema's source of truth is** — `zt doctor`
53
+ reports which mode this app is in.
54
+
55
+ - **Models are the source of truth** (`synchronize` on). You do not need a migration. The
56
+ mixin registers `email_verified_at` imperatively and the table is built from what the
57
+ models declare, so the column comes with it.
58
+ - **Migrations are the source of truth** (`synchronize` off — the production default). You
59
+ do. The boot-time concern only _adds_ a column to a table that already exists; it never
60
+ revisits one it has seen. A `create users` migration that does not mention
61
+ `email_verified_at` produces a table without it, and then every query touching the column
62
+ fails with `no such column: email_verified_at` — all at once, in tests that have nothing to
63
+ do with email, with nothing pointing at the mixin.
64
+
65
+ `zt doctor` fails on that combination and names the table, so you find it before the suite
66
+ does.
67
+
68
+ Write the migration **guarded**:
53
69
 
54
70
  ```typescript fragment
55
71
  // database/migrations/002_add_email_verified_at.ts
56
72
  export default class AddEmailVerifiedAt extends Migration {
57
73
  async up(schema: Schema) {
74
+ if (await schema.hasColumn("users", "email_verified_at")) return;
58
75
  await schema.table("users", (table) => {
59
76
  table.timestamp("email_verified_at").nullable();
60
77
  });
@@ -67,6 +84,14 @@ export default class AddEmailVerifiedAt extends Migration {
67
84
  }
68
85
  ```
69
86
 
87
+ The guard is not defensive habit. Any database that has booted the app since the mixin was
88
+ composed already **has** the column — the concern added it. An unguarded migration then fails
89
+ with `duplicate column name`, during the `migrate` step of `deploy:production`, which is the
90
+ worst possible moment to learn this.
91
+
92
+ `remember_token` from `Authenticatable` is provisioned the same way and carries the same
93
+ condition.
94
+
70
95
  ## User model
71
96
 
72
97
  Expose the column and a convenience getter the middleware and controller can read:
@@ -373,6 +373,27 @@ it works only after the app has booted. In a unit test that never boots, constru
373
373
  | `Url.sign(base, params?, min?, secret?)` | `(base: string, params?, expiresInMinutes?, secret?) => string` | Build an HMAC-signed, time-limited URL. |
374
374
  | `Url.verify(url, secret?)` | `(signedUrl: string, secret?: string) => boolean` | Verify a signed URL; `false` if tampered or expired. |
375
375
 
376
+ ## Hashing helpers
377
+
378
+ Three pure helpers sit beside `Crypt`, for the cases that are not encryption:
379
+
380
+ | Helper | Use |
381
+ | --------------------- | ---------------------------------------------------------------------- |
382
+ | `sha256Hex(value)` | A hex digest. For a lookup key or a blind index, never for a password. |
383
+ | `hmacHex(value, key)` | A keyed digest — a webhook signature, a tamper-evident token. |
384
+ | `safeEqual(a, b)` | Constant-time comparison. Use it for every secret comparison. |
385
+
386
+ **`safeEqual` is the one that matters.** `a === b` on a token returns as soon as two bytes
387
+ differ, and the time it took is a measurement of how much of the prefix was right — enough, over
388
+ many attempts, to recover a secret a character at a time. Comparing anything an attacker
389
+ supplies against anything you hold goes through `safeEqual`.
390
+
391
+ ### Signed URLs
392
+
393
+ `URLSigner` is what `signedUrl()` and `ValidateSignatureMiddleware` are built on. Reach for it
394
+ directly when you need to sign or verify outside a request — a link minted by a scheduled job,
395
+ or a signature checked by a worker.
396
+
376
397
  ## Next steps
377
398
 
378
399
  - [Authentication](/docs/authentication) — where hashed passwords are verified at login.
package/docs/errors.md CHANGED
@@ -273,6 +273,8 @@ So a package that owns an error class can contribute a **diagnosis**, rendered a
273
273
 
274
274
  It works on SQLite, PostgreSQL and MySQL — matched on the driver's error code where there is one (`42P01`, `42703`, `1146`, `1054`) and on the message otherwise.
275
275
 
276
+ `bun zt doctor` asks the same question before anything breaks, and warns when migrations are pending — see [Migrations](/docs/migrations#running-migrations). The overlay is reactive by nature: it needs a request to have already failed, which means somebody has already lost the thread of what they were doing.
277
+
276
278
  > **The button exists only in development.** The endpoint behind it refuses unless [`devSurfacesEnabled()`](/docs/deployment) is true, and that gate **fails closed**: an unset `APP_ENV` does not qualify. The route is not even registered otherwise.
277
279
  >
278
280
  > It also requires a single-use token minted into the page, and passes the same origin check the WebSocket endpoints use. A dev server on `localhost:3000` is reachable by any site you have open in another tab, and "run every pending migration" is not something a random page should be able to trigger.
@@ -859,6 +859,60 @@ Content-Security-Policy:
859
859
  }
860
860
  ```
861
861
 
862
+ ## Types
863
+
864
+ Every built-in component exports the type of its own props, named after it. Reach for one when
865
+ you wrap a component rather than use it directly — the usual reason an app needs the type at all:
866
+
867
+ ```tsx
868
+ import { Modal, type ModalProps } from "@zerotal/flow";
869
+
870
+ /** Our confirm dialog: the same API, one decision already made. */
871
+ export function ConfirmModal(props: Omit<ModalProps, "closeable">) {
872
+ return <Modal closeable={false} {...props} />;
873
+ }
874
+ ```
875
+
876
+ ```text
877
+ DescriptionProps DisclosureProps DrawerProps DropdownProps
878
+ ErrorBoundaryProps ErrorProps ErrorsProps
879
+ FieldsetProps FileUploadProps FlashProps ForProps
880
+ InfiniteScrollProps LegendProps LinkProps
881
+ ListboxProps LoadingProps ModalProps PagerProps
882
+ SectionContentProps SectionOutletProps
883
+ VirtualizeProps
884
+ ```
885
+
886
+ `ListboxOption` is one entry in a `<Listbox>`, and `UploadRef` is what `<FileUpload>` hands back
887
+ for a file the server has accepted.
888
+
889
+ ### The flash API
890
+
891
+ `this.flash(…)` is the short form. The builder underneath it is exported, and so are the shapes
892
+ it produces — worth having when a helper composes a message rather than writing one inline:
893
+
894
+ | Type | What it is |
895
+ | -------------------- | ------------------------------------------------------------------------ |
896
+ | `FlashLevel` | `"info" \| "success" \| "warning" \| "error"` — what colours the toast. |
897
+ | `FlashMessage` | One flash: its level, text, and any actions. |
898
+ | `FlashOptions` | Per-message overrides — duration, position, whether it can be dismissed. |
899
+ | `FlashBuilder` | The fluent form, for a message assembled in more than one step. |
900
+ | `FlashPosition` | Where the container puts it, matching `<Flash position>`. |
901
+ | `FlashAction` | A button on the toast: its label and what it does. |
902
+ | `FlashActionStyle` | How that button is drawn. |
903
+ | `FlashActionVariant` | Its emphasis. |
904
+ | `FlashCallback` | What runs when the action is pressed. |
905
+ | `RedirectFlash` | A flash that survives a redirect, so it appears on the page you land on. |
906
+
907
+ `ErrorField` and `ValidationRules` are the validation side of the same story: what one field's
908
+ errors look like, and the rules a component declares.
909
+
910
+ ### Elsewhere
911
+
912
+ `DurableOption` configures `@durable` state, `SessionOptions` the session a component reads, and
913
+ `UrlOptions` how `@url` state is written into the query string. `EventName` is the union of
914
+ events a component can listen for.
915
+
862
916
  ## Next steps
863
917
 
864
918
  - [Flow overview](/docs/flow) — the guide's front page and the rest of the sections.
@@ -545,6 +545,63 @@ export class AvatarUploader extends Component {
545
545
  dedicated `temp` disk are natural follow-ups; the `TemporaryUploadedFile` API is designed to
546
546
  absorb them without changing component code.
547
547
 
548
+ ## Enhancing a plain form — no component
549
+
550
+ Everything above needs a Flow component. This does not.
551
+
552
+ A page that is just server-rendered HTML — no `Router.flow`, no `Component` — can still have a
553
+ form that submits without the page flashing. Add `data-enhance`:
554
+
555
+ ```html
556
+ <form method="post" action="/subscribe" data-enhance>
557
+ <p class="error">{{ error }}</p>
558
+ <input name="email" />
559
+ <button type="submit">Subscribe</button>
560
+ </form>
561
+ ```
562
+
563
+ The form posts through `fetch`, and **the matching form in the response replaces this one in
564
+ place**. A validation error re-renders the form with the message in it, and that is what lands
565
+ on the page — the rest of the document is untouched, so nothing scrolls, nothing flashes, and
566
+ what the person typed is still there.
567
+
568
+ ### Include the script
569
+
570
+ The enhancement is a separate, dependency-free bundle. Flow pages get
571
+ `/__flow/runtime.js`; a plain page gets nothing, which is the whole reason this exists. Put the
572
+ tag in the layout that renders your non-Flow pages:
573
+
574
+ ```tsx
575
+ import { flowEnhanceTag } from "@zerotal/flow";
576
+
577
+ // in your layout's <head>
578
+ flowEnhanceTag(); // <script src="/__flow/enhance.js" defer></script>
579
+ ```
580
+
581
+ Nothing fails if you forget it — the forms simply post the way they always did. That is the
582
+ design, and it is also why the tag is easy to leave out and never notice.
583
+
584
+ ### What it does
585
+
586
+ | Situation | What happens |
587
+ | ---------------- | ------------------------------------------------------------------------------- |
588
+ | Normal submit | The response's matching form replaces this one; focus and caret are restored. |
589
+ | Validation error | Same — the server re-rendered the form, so the error is in the markup. |
590
+ | Redirect | Followed, the document swapped, and `pushState`d so the address bar agrees. |
591
+ | Network failure | Falls back to a native submit, so nothing typed is lost. |
592
+ | No JavaScript | An ordinary form post. `data-enhance` is additive; there is no fallback to rot. |
593
+
594
+ `data-enhance-target="#selector"` replaces something else instead of the form — a results
595
+ panel, a list. `data-enhance="false"` opts a form out. While a submission is in flight the form
596
+ carries `data-enhance-busy`, which is a styling hook and a re-entry guard.
597
+
598
+ A `flow:enhanced` event fires on `window` after each swap, with
599
+ `detail.navigated` saying whether it followed a redirect.
600
+
601
+ > **Note** — This is enhancement, not a component. There is no state, no socket and no server
602
+ > round-trip beyond the form's own post. When the page needs live state, reach for a
603
+ > [component](/docs/flow/components).
604
+
548
605
  ## Next steps
549
606
 
550
607
  - [Flow overview](/docs/flow) — the guide's front page and the rest of the sections.
@@ -216,6 +216,20 @@ override async render() {
216
216
  | `x-show="$flow.open && $flow.count > 0"` | Raw Alpine expression |
217
217
  | `flow:click="increment"` | Hand-written Flow directive (accepted but not needed in JSX) |
218
218
 
219
+ ## Plain-form enhancement
220
+
221
+ For forms on pages with no Flow component. See
222
+ [Forms](/docs/flow/forms#enhancing-a-plain-form--no-component).
223
+
224
+ | Attribute / export | Meaning |
225
+ | --------------------- | -------------------------------------------------------------------------------------- |
226
+ | `data-enhance` | Submit through `fetch` and patch the response in. `"false"` opts out. |
227
+ | `data-enhance-target` | CSS selector for what to replace, instead of the form itself. |
228
+ | `data-enhance-busy` | Set on the form while a submission is in flight. Styling hook and re-entry guard. |
229
+ | `flowEnhanceTag()` | The `<script>` tag to put in a non-Flow layout. |
230
+ | `FLOW_ENHANCE_PATH` | `/__flow/enhance.js` — the path the bundle is served at. |
231
+ | `flow:enhanced` | Window event after each swap; `detail.navigated` says whether a redirect was followed. |
232
+
219
233
  ## Client expressions
220
234
 
221
235
  Inside a client expression — `onClick={() => this.X(...)}` — `this.` resolves to the live client runtime (no server round-trip to start it). You write the **same names as on the server** — no `$`-prefixed syntax, and it all type-checks.
@@ -16,6 +16,44 @@ provider.
16
16
  is configured for **SQLite**, which needs nothing installed — PostgreSQL and
17
17
  MySQL are supported and are a `DATABASE_URL` away.
18
18
 
19
+ ### One project, one Bun
20
+
21
+ `engines.bun` is a floor, not a lock, and nothing in npm enforces it. A project can end up
22
+ with two runtimes in it without anyone choosing that: the shell's `bun`, and a different one
23
+ in `node_modules/bun`, put there by a transitive peer dependency nobody declared. The work
24
+ then splits between them — the server served by one, the suite run by the other — and nothing
25
+ says so.
26
+
27
+ That is expensive because the difference is real but narrow. The SQLite bindings, `node:`
28
+ compatibility and the test runner itself all change between releases, so a couple of
29
+ assertions happen to be runtime-sensitive and the rest are not. When those two fail you go
30
+ looking for a bug in the code they touch. And a suite that passes is not evidence either: it
31
+ only means no test happened to stand on a difference.
32
+
33
+ So **`zt` refuses to run when the two disagree**:
34
+
35
+ ```
36
+ Two Bun runtimes are in play. This process is Bun 1.3.14, but the project
37
+ installs Bun 1.4.0 (/srv/app/node_modules/bun/package.json).
38
+ ```
39
+
40
+ This is not a pin. The version to agree on is whichever one the project installed, so
41
+ `bun update bun` moves it and nothing needs editing — what is enforced is that there is only
42
+ one. Fix it either way:
43
+
44
+ ```bash
45
+ bun update bun # move the installed one to match your shell
46
+ node_modules/.bin/bun # or run everything through the installed one
47
+ ```
48
+
49
+ Most projects never see this, because most have no `bun` in `node_modules` to disagree with.
50
+ To boot anyway — mid-upgrade, say — set `ZT_ALLOW_RUNTIME_MISMATCH=1`, which downgrades the
51
+ refusal to a warning. The refusal is a `RuntimeMismatchError`, which carries both versions in
52
+ its `context` for anything scripting around it.
53
+
54
+ `zt test` spawns the binary running it, not whatever `bun` resolves to on `PATH`, so the
55
+ suite runs on the runtime the guard just checked.
56
+
19
57
  ## Create a new project
20
58
 
21
59
  ```bash
package/docs/health.md CHANGED
@@ -271,6 +271,25 @@ What a check returns to describe its own state. A check may also return nothing
271
271
  | `message` | `string` | Optional human-readable detail. |
272
272
  | `meta` | `Record<string, unknown>` | Optional structured metadata surfaced in the report. |
273
273
 
274
+ ### Types
275
+
276
+ | Type | What it is |
277
+ | ---------------------- | --------------------------------------------------------------------------- |
278
+ | `HealthStatus` | `"ok" \| "degraded" \| "down"` — what one check, and the aggregate, report. |
279
+ | `HealthCheckReport` | One check's result: its name, status, duration, and any message. |
280
+ | `HealthConfigShape` | What `HealthConfig()` accepts. |
281
+ | `ResolvedHealthConfig` | The same after defaults are filled in — what the endpoint actually reads. |
282
+
283
+ ### The doctor's own types
284
+
285
+ `zt doctor` is extensible: a provider contributes checks through `doctorChecks()`, and
286
+ `runDoctor(app)` runs them all. A check returns a `DoctorCheckResult` — `ok`, `warn` or `fail`,
287
+ with a message and the `fix` printed under it — and the report is `DoctorReportEntry[]`, each
288
+ pairing a check with what it found.
289
+
290
+ A `fail` refuses a deploy; a `warn` does not. Choose deliberately: a check that warns about
291
+ something fatal is ignored, and one that fails on something conditional gets worked around.
292
+
274
293
  ## Next steps
275
294
 
276
295
  - [Telemetry](/docs/telemetry) — collect the metrics behind your readiness checks.