@zerotal/arch 1.8.1 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/api-surface.md +6 -6
  2. package/docs/admin/actions.md +15 -0
  3. package/docs/admin/auth.md +10 -0
  4. package/docs/admin/dashboard.md +12 -0
  5. package/docs/admin/extending-ui.md +14 -0
  6. package/docs/admin/forms.md +15 -0
  7. package/docs/admin/operations.md +12 -0
  8. package/docs/admin/resources.md +6 -0
  9. package/docs/admin/tables.md +21 -0
  10. package/docs/audit.md +5 -0
  11. package/docs/authentication.md +110 -1
  12. package/docs/broadcasting/references.md +17 -0
  13. package/docs/cache.md +5 -0
  14. package/docs/carbon.md +5 -0
  15. package/docs/changelog.md +281 -0
  16. package/docs/client/index.md +17 -0
  17. package/docs/commands.md +6 -0
  18. package/docs/components.md +73 -0
  19. package/docs/config-system.md +54 -0
  20. package/docs/cookies.md +6 -0
  21. package/docs/deployment.md +151 -13
  22. package/docs/devtools.md +5 -0
  23. package/docs/email-verification.md +26 -1
  24. package/docs/encryption.md +21 -0
  25. package/docs/errors.md +2 -0
  26. package/docs/flow/components.md +54 -0
  27. package/docs/flow/forms.md +57 -0
  28. package/docs/flow/references.md +14 -0
  29. package/docs/getting-started.md +38 -0
  30. package/docs/health.md +19 -0
  31. package/docs/helpers.md +150 -0
  32. package/docs/i18n.md +5 -0
  33. package/docs/inertia/middleware.md +44 -0
  34. package/docs/inertia/props.md +70 -0
  35. package/docs/inertia/ssr.md +95 -10
  36. package/docs/lock.md +15 -0
  37. package/docs/logger.md +38 -0
  38. package/docs/middleware.md +31 -0
  39. package/docs/migrations.md +47 -0
  40. package/docs/monitor.md +59 -0
  41. package/docs/notifications.md +11 -0
  42. package/docs/orm/casts.md +6 -0
  43. package/docs/orm/lifecycle.md +18 -0
  44. package/docs/orm/queries.md +10 -0
  45. package/docs/orm/relationships.md +30 -0
  46. package/docs/queue.md +10 -0
  47. package/docs/rate-limiting.md +84 -21
  48. package/docs/responses.md +23 -0
  49. package/docs/routing.md +16 -0
  50. package/docs/scheduler.md +82 -8
  51. package/docs/session.md +6 -0
  52. package/docs/social.md +10 -0
  53. package/docs/storage.md +21 -0
  54. package/docs/support-policy.md +13 -1
  55. package/docs/telemetry.md +8 -0
  56. package/docs/tenancy.md +6 -0
  57. package/docs/testing/index.md +105 -0
  58. package/docs/upgrade.md +48 -0
  59. package/docs/validator.md +9 -0
  60. package/docs/view.md +6 -0
  61. package/package.json +3 -3
  62. package/src/install/guidelines.ts +1 -1
  63. package/src/mcp/stdio.ts +3 -3
  64. package/src/tools/_probe.ts +2 -2
@@ -38,6 +38,7 @@ deliberate: systemd, your container runtime or your deploy script owns process
38
38
  lifecycle, and this gives it a gate to restart behind.
39
39
 
40
40
  ```bash
41
+ bun zt deploy:production --check # run the gate only — see below
41
42
  bun zt deploy:production --dry-run # print the plan, run none of it
42
43
  bun zt deploy:production --skip-migrations # release without touching the schema
43
44
  bun zt deploy:production --probe=https://example.com # real handshake at the end
@@ -63,18 +64,46 @@ the wrong database.
63
64
 
64
65
  Each entry is a `DeployTarget`:
65
66
 
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. |
67
+ | Field | Meaning |
68
+ | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
69
+ | `url` | The public URL in this environment. What `--probe` handshakes against when given no URL of its own. |
70
+ | `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. |
71
+ | `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
72
 
71
73
  Omit the file entirely and you get `DEFAULT_DEPLOY_TARGETS`: `production` and
72
74
  `staging`, both with the default steps.
73
75
 
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:
76
+ ### Your own release gate
77
+
78
+ The framework's preflight knows the things a framework can know: that this really is the
79
+ environment you think it is, that the config validators pass, that `doctor` is happy
80
+ which now includes refusing a production release whose `mail.driver` is still `log`,
81
+ because mail written to a log file is delivered to nobody and says so nowhere. It
82
+ cannot know that this workspace has no cancellation policy, or that the owner account
83
+ is still on the password `admin:create` issued it. Those refusals are yours.
84
+
85
+ Write them as a command and name it `release:check` (exported as
86
+ `CONVENTIONAL_PREFLIGHT_COMMAND`). The pipeline finds it by name — nothing to wire up:
87
+
88
+ ```ts
89
+ // app/commands/ReleaseCheckCommand.ts
90
+ import { Command } from "zerotal";
91
+
92
+ export class ReleaseCheckCommand extends Command {
93
+ static commandName = "release:check";
94
+ static description = "Refuse a release this app is not ready for";
95
+ static needsApp = true;
96
+
97
+ async run(): Promise<void> {
98
+ const problems: string[] = [];
99
+ if (Bun.env["APP_KEY"] === "base64:CHANGE_ME") problems.push("APP_KEY is the example one.");
100
+ if (problems.length > 0) throw new Error(problems.join(" "));
101
+ }
102
+ }
103
+ ```
104
+
105
+ A throw, or any non-zero exit, stops the release before `assets:build` has run. To run
106
+ something else — or more than one thing — name them:
78
107
 
79
108
  ```ts
80
109
  // config/deploy.ts
@@ -82,19 +111,42 @@ export default {
82
111
  targets: {
83
112
  production: {
84
113
  url: "https://example.com",
85
- steps: ["release:check", "assets:build", "inertia:build", "migrate"],
114
+ preflight: ["release:check", "smoke:mail"],
86
115
  },
87
116
  },
88
117
  };
89
118
  ```
90
119
 
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.
120
+ A name in `preflight` that is not a registered command **fails the deploy** rather than being
121
+ skipped. That is the opposite of how `steps` treats an absent command, and deliberately so: a
122
+ missing `inertia:build` means the app has no Inertia, while a missing gate means the gate is
123
+ not running — which is the state this exists to prevent. A gate nothing calls is a comment.
93
124
 
94
125
  Two things worth adding while you are there: `assets:build` and `inertia:build` accept
95
126
  `--clean`, which removes anything in the output directory the build did not write — see
96
127
  [Build assets](#build-assets).
97
128
 
129
+ ### `--check`: the gate on its own
130
+
131
+ A release script has a moment where the new code is on disk and the service has not
132
+ restarted yet. That is the moment to ask whether this release is fit to go live, and
133
+ `--check` is the whole preflight and nothing else:
134
+
135
+ ```bash
136
+ # on the box, after the new release is unpacked and before the restart
137
+ APP_ENV=production bun zt deploy:production --check || exit 1
138
+ systemctl restart app
139
+ ```
140
+
141
+ It runs the environment check, the config validators with production semantics,
142
+ `doctor`, and your own `release:check` — everything that can refuse — and builds
143
+ nothing, migrates nothing, restarts nothing. Exit 0 and restart; exit non-zero and
144
+ keep serving the previous release, which is the point. A workspace that has lost its
145
+ banking details, or had its mail driver knocked back to `log`, never goes live broken.
146
+
147
+ `--check` and `--dry-run` answer different questions. `--dry-run` prints the plan
148
+ without running any of it, including the gate. `--check` runs the gate for real.
149
+
98
150
  > **Note** — `deploy:<env>` runs **where the app runs**, with that environment's
99
151
  > variables. It does not reach another machine over SSH. Run it on the box, or in
100
152
  > the container build, as the step before the restart.
@@ -180,6 +232,54 @@ bun zt migrate
180
232
  Auto-`synchronize` is **hard-off in production** — generate and commit
181
233
  [migrations](/docs/migrations) during development and run them on deploy.
182
234
 
235
+ ## Back up the database
236
+
237
+ On SQLite the database is one file, which makes `cp` look like a backup. It is not one. A
238
+ live SQLite database has pages in flight; copying the file while the server is serving can
239
+ capture a half-written page, and the result is a file that sits in your retention directory
240
+ for months and turns out to be corrupt on the one morning you open it.
241
+
242
+ `zt db:backup` uses SQLite's own `VACUUM INTO`, which takes a read lock and writes a
243
+ complete database while the server keeps serving. It needs no `sqlite3` binary on the box:
244
+
245
+ ```bash
246
+ # in your project root
247
+ bun zt db:backup --dir=/var/backups/app --keep=30 --require-rows=bookings,invoices
248
+ ```
249
+
250
+ | Flag | What it does |
251
+ | ---------------- | ---------------------------------------------------------------------------- |
252
+ | `--dir` | Where snapshots go. Default `storage/backups`. |
253
+ | `--keep` | How many to keep, newest first. `0` keeps every one. Default `14`. |
254
+ | `--require-rows` | Tables that must not be empty in the snapshot. **Set this.** |
255
+ | `--rehearse` | Also perform the restore — copy the snapshot, open the copy, check it there. |
256
+
257
+ Every snapshot is opened and integrity-checked the moment it is written, and **every failure
258
+ path exits non-zero**. That is what makes it safe to run from a timer: a bad night leaves a
259
+ failed unit somebody can see, rather than a green one and no file.
260
+
261
+ `--require-rows` is the flag that turns "a file was written" into "the file has the business
262
+ in it". An empty `bookings` table in a snapshot of a live system is not a small discrepancy,
263
+ and it is invisible in a byte count.
264
+
265
+ Run `--rehearse` on a schedule of its own — weekly is plenty. A backup nobody has restored is
266
+ a hope, and the restore is the operation you will be doing at 3am.
267
+
268
+ ```ini
269
+ # /etc/systemd/system/app-backup.service
270
+ [Service]
271
+ Type=oneshot
272
+ WorkingDirectory=/srv/app
273
+ Environment=APP_ENV=production
274
+ ExecStart=/srv/app/node_modules/.bin/bun zt db:backup --keep=30 --require-rows=bookings
275
+ ```
276
+
277
+ Pair it with a `.timer`, and let the failed unit be your alert. Retention is handled by
278
+ `--keep`; anything the command did not write is never touched.
279
+
280
+ On PostgreSQL or MySQL the command refuses and points at `pg_dump` / `mysqldump`, which is
281
+ where that job belongs.
282
+
183
283
  ## Build assets
184
284
 
185
285
  Build the frontend bundle as a release step, so `public/` holds compiled output before
@@ -233,6 +333,31 @@ Ordering matters if the old release is still serving traffic: clearing the direc
233
333
  its bundles away, so do it as close to the swap as you can, or stage the release in a new
234
334
  directory and move it into place.
235
335
 
336
+ ### `assets:prune` — clearing up after the extract instead of before it
337
+
338
+ Clearing the directory first has a cost the ordering note above hints at: between the
339
+ `rm -rf` and the new files landing, the release still serving traffic has no bundles.
340
+ If you would rather extract over the top and tidy afterwards, `assets:build` records
341
+ what it wrote and `assets:prune` removes what that record does not claim:
342
+
343
+ ```bash
344
+ # on the build machine — the record is written under .zerotal/
345
+ bun zt assets:build
346
+ tar -czf release.tgz public .zerotal # ship the record with the release
347
+
348
+ # on the server, after extracting and before the restart
349
+ bun zt assets:prune --dry-run # list what would go
350
+ bun zt assets:prune
351
+ ```
352
+
353
+ **Ship `.zerotal/` with the release** — that is the part that makes it work. Without
354
+ the record, "which files belong to this release" has no answer on a server that never
355
+ ran a build, and `assets:prune` says so and removes nothing rather than guessing.
356
+
357
+ It is conservative by design: it deletes a file only when the record does not claim it
358
+ _and_ it is named the way the bundler names a code-split chunk. Your images and
359
+ favicon are never candidates, because an output directory is usually `public/`.
360
+
236
361
  ### `--clean` for a directory the build does not own outright
237
362
 
238
363
  The cleanup above recognises the filenames `Bun.build()` produces. An app that sets its own
@@ -350,6 +475,11 @@ The symptom is a legitimate visitor getting a 429 they did not earn.
350
475
  Count the proxies you actually run. Setting `trustedProxies: 3` with one proxy in front
351
476
  reads an entry the client supplied.
352
477
 
478
+ `zt doctor` warns about this: a production-like deployment with a registered throttle and no
479
+ `trustedProxies` is reported as almost certainly wrong. It is a warning rather than a failure
480
+ because the framework cannot see your deployment — an app served directly, with nothing in
481
+ front of it, is correctly configured exactly as it stands.
482
+
353
483
  ### Never gate the transport path
354
484
 
355
485
  **Browsers do not attach basic-auth credentials to a WebSocket handshake.** An HTTP auth
@@ -513,8 +643,9 @@ Run the worker as a **second** container/service from the same image with the co
513
643
  `bun zt worker`.
514
644
 
515
645
  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:
646
+ `postinstall` shells out to `node`. The `bun` npm package is the usual one — its
647
+ `postinstall` downloads a Bun binary the box already has, and there is no `node` to run the
648
+ script with. `--ignore-scripts` resolves it, but check what you are skipping first:
518
649
 
519
650
  ```bash
520
651
  # every package with an install script, before you skip them all
@@ -523,6 +654,13 @@ for p in node_modules/*/package.json node_modules/@*/*/package.json; do
523
654
  done
524
655
  ```
525
656
 
657
+ ### `startZerotal` options
658
+
659
+ `StartZerotalOptions` is what `zt.ts` may pass — currently `configDir`, for an app whose config
660
+ does not live at `./config`. `isDevSurfaceAllowed(env)` is the check every dev-only surface
661
+ gates on, exported so an app's own dev tooling can gate the same way; it **fails closed**, so an
662
+ unset `APP_ENV` does not qualify.
663
+
526
664
  ## Next steps
527
665
 
528
666
  - [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.