@zerotal/arch 1.7.4 → 1.8.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 (126) hide show
  1. package/CHANGELOG.md +86 -2
  2. package/README.md +4 -2
  3. package/api-surface.md +2 -0
  4. package/docs/about.md +16 -16
  5. package/docs/admin/actions.md +9 -9
  6. package/docs/admin/auth.md +2 -2
  7. package/docs/admin/dashboard.md +5 -5
  8. package/docs/admin/extending-ui.md +12 -12
  9. package/docs/admin/extending.md +16 -7
  10. package/docs/admin/forms.md +4 -4
  11. package/docs/admin/index.md +2 -2
  12. package/docs/admin/operations.md +9 -9
  13. package/docs/admin/resources.md +3 -3
  14. package/docs/admin/structure.md +6 -6
  15. package/docs/admin/tables.md +8 -8
  16. package/docs/admin/testing.md +4 -4
  17. package/docs/ai.md +118 -11
  18. package/docs/application.md +12 -12
  19. package/docs/arch.md +25 -3
  20. package/docs/assets.md +11 -11
  21. package/docs/audit.md +13 -13
  22. package/docs/authentication.md +39 -39
  23. package/docs/authorization.md +13 -13
  24. package/docs/broadcasting/channels.md +2 -2
  25. package/docs/broadcasting/client.md +2 -2
  26. package/docs/broadcasting/events.md +9 -10
  27. package/docs/broadcasting/index.md +1 -1
  28. package/docs/broadcasting/testing.md +5 -5
  29. package/docs/cache.md +15 -13
  30. package/docs/carbon.md +35 -35
  31. package/docs/changelog.md +268 -1
  32. package/docs/client/index.md +13 -13
  33. package/docs/commands.md +36 -4
  34. package/docs/components.md +116 -115
  35. package/docs/config-system.md +4 -4
  36. package/docs/container.md +27 -27
  37. package/docs/context.md +27 -27
  38. package/docs/contributing.md +28 -0
  39. package/docs/controllers.md +10 -10
  40. package/docs/conventions.md +23 -15
  41. package/docs/cookies.md +3 -3
  42. package/docs/csrf.md +6 -6
  43. package/docs/database.md +22 -17
  44. package/docs/deployment.md +99 -2
  45. package/docs/devtools.md +16 -16
  46. package/docs/email-verification.md +8 -8
  47. package/docs/encryption.md +7 -7
  48. package/docs/errors.md +6 -6
  49. package/docs/events.md +12 -11
  50. package/docs/flow/components.md +32 -32
  51. package/docs/flow/decorators.md +18 -18
  52. package/docs/flow/events.md +16 -16
  53. package/docs/flow/forms.md +17 -17
  54. package/docs/flow/icons.md +5 -5
  55. package/docs/flow/index.md +21 -21
  56. package/docs/flow/layouts.md +99 -35
  57. package/docs/flow/lifecycle.md +16 -16
  58. package/docs/flow/models.md +9 -9
  59. package/docs/flow/pagination.md +8 -8
  60. package/docs/flow/performance.md +7 -7
  61. package/docs/flow/references.md +3 -3
  62. package/docs/flow/routing.md +26 -26
  63. package/docs/flow/testing.md +20 -20
  64. package/docs/getting-started.md +18 -13
  65. package/docs/health.md +17 -8
  66. package/docs/helpers.md +17 -17
  67. package/docs/i18n.md +10 -10
  68. package/docs/inertia/devtools.md +4 -4
  69. package/docs/inertia/index.md +6 -6
  70. package/docs/inertia/props.md +21 -21
  71. package/docs/inertia/rendering.md +9 -9
  72. package/docs/inertia/ssr.md +2 -2
  73. package/docs/lifecycle.md +2 -2
  74. package/docs/lock.md +11 -11
  75. package/docs/logger.md +12 -12
  76. package/docs/media.md +22 -22
  77. package/docs/middleware.md +17 -17
  78. package/docs/migrations.md +13 -13
  79. package/docs/monitor.md +6 -6
  80. package/docs/notifications.md +24 -24
  81. package/docs/orm/casts.md +14 -14
  82. package/docs/orm/factories.md +8 -8
  83. package/docs/orm/index.md +22 -20
  84. package/docs/orm/lifecycle.md +13 -13
  85. package/docs/orm/queries.md +28 -28
  86. package/docs/orm/relationships.md +16 -16
  87. package/docs/orm/serialization.md +11 -11
  88. package/docs/package-development.md +10 -10
  89. package/docs/pagination.md +14 -14
  90. package/docs/password-reset.md +6 -6
  91. package/docs/providers.md +11 -11
  92. package/docs/query-builder.md +18 -18
  93. package/docs/queue.md +14 -12
  94. package/docs/rate-limiting.md +6 -6
  95. package/docs/responses.md +14 -14
  96. package/docs/roles-and-2fa.md +9 -9
  97. package/docs/routing.md +44 -44
  98. package/docs/scaffolding.md +1 -1
  99. package/docs/scheduler.md +10 -10
  100. package/docs/seeding.md +6 -6
  101. package/docs/session.md +16 -14
  102. package/docs/social.md +14 -14
  103. package/docs/storage.md +25 -13
  104. package/docs/structure.md +2 -2
  105. package/docs/support-policy.md +23 -10
  106. package/docs/telemetry.md +12 -12
  107. package/docs/tenancy.md +34 -40
  108. package/docs/testing/browser.md +6 -6
  109. package/docs/testing/console.md +3 -3
  110. package/docs/testing/database.md +10 -10
  111. package/docs/testing/flow-browser.md +6 -6
  112. package/docs/testing/http.md +12 -12
  113. package/docs/testing/index.md +29 -3
  114. package/docs/testing/mocking.md +8 -8
  115. package/docs/upgrade.md +1 -1
  116. package/docs/validator.md +19 -19
  117. package/docs/view.md +14 -14
  118. package/package.json +4 -4
  119. package/src/config.ts +8 -0
  120. package/src/index.ts +25 -1
  121. package/src/install/ArchInstallCommand.ts +46 -3
  122. package/src/install/guidelines.ts +84 -0
  123. package/src/install/shape.ts +177 -0
  124. package/src/install/skills.ts +271 -0
  125. package/src/provider/ArchProvider.ts +148 -0
  126. package/src/tools/searchDocs.ts +7 -0
@@ -42,7 +42,7 @@ app/providers/* → register → boot phases → app/middleware/* →
42
42
 
43
43
  Every `Model` subclass under `app/models/` is registered automatically:
44
44
 
45
- ```typescript
45
+ ```typescript fragment
46
46
  // app/models/User.ts
47
47
  import { Model, column, hasMany } from "@zerotal/orm";
48
48
  import { Post } from "./Post.ts";
@@ -65,7 +65,7 @@ No `@table` needed. The table name is derived by convention — `pluralize(snake
65
65
 
66
66
  Override the name (or set timestamps/soft-deletes) with `@table` whenever you need to:
67
67
 
68
- ```typescript
68
+ ```typescript fragment
69
69
  // app/models/Account.ts
70
70
  @table("legacy_accounts", { softDeletes: true })
71
71
  export class Account extends Model {
@@ -86,7 +86,7 @@ export class Account extends Model {
86
86
  `XObserver` is attached to model `X` automatically (the `Observer` suffix is stripped and matched
87
87
  against the discovered models):
88
88
 
89
- ```typescript
89
+ ```typescript fragment
90
90
  // app/observers/UserObserver.ts
91
91
  import type { ModelObserver } from "@zerotal/orm";
92
92
  import type { User } from "../models/User.ts";
@@ -107,7 +107,7 @@ Override the target with `static model = SomeModel` when the name doesn't match.
107
107
 
108
108
  `XPolicy` is registered with the Gate for model `X`:
109
109
 
110
- ```typescript
110
+ ```typescript fragment
111
111
  // app/policies/PostPolicy.ts
112
112
  import { Policy } from "@zerotal/auth";
113
113
  import type { Post } from "../models/Post.ts";
@@ -130,14 +130,14 @@ model's class automatically. Override the target with `static model = Post`.
130
130
 
131
131
  A listener declares the event(s) it handles via `static listens`:
132
132
 
133
- ```typescript
133
+ ```typescript fragment
134
134
  // app/events/UserRegistered.ts
135
135
  export class UserRegistered {
136
136
  constructor(public user: User) {}
137
137
  }
138
138
  ```
139
139
 
140
- ```typescript
140
+ ```typescript fragment
141
141
  // app/listeners/SendWelcomeEmail.ts
142
142
  import { UserRegistered } from "../events/UserRegistered.ts";
143
143
 
@@ -158,7 +158,7 @@ them and runs any module-level side effects before listeners bind).
158
158
  A model can map its lifecycle events to event classes, which are dispatched on the bus when they
159
159
  fire — listeners then react with no coupling to the model:
160
160
 
161
- ```typescript
161
+ ```typescript fragment
162
162
  // app/models/Order.ts
163
163
  export class Order extends Model {
164
164
  static dispatchesEvents = { created: OrderPlaced, deleted: OrderCancelled };
@@ -181,10 +181,18 @@ Any `ServiceProvider` under `app/providers/` is registered automatically — you
181
181
  (`onRegister` → `onBooting` → `onBooted`), appended **after** the providers you registered
182
182
  explicitly (so framework providers boot first), and de-duplicated if also listed by hand.
183
183
 
184
- ```typescript
184
+ ```typescript fragment
185
185
  // app/providers/AppServiceProvider.ts
186
186
  import { ServiceProvider } from "zerotal";
187
187
 
188
+ // A string token is only valid once it is declared, which is what keeps
189
+ // `make("billing")` typed at the call site. See Container for the full pattern.
190
+ declare module "zerotal" {
191
+ interface ContainerBindings {
192
+ billing: Billing;
193
+ }
194
+ }
195
+
188
196
  export class AppServiceProvider extends ServiceProvider {
189
197
  override onRegister() {
190
198
  this.app.container.singleton("billing", () => new Billing());
@@ -197,7 +205,7 @@ export class AppServiceProvider extends ServiceProvider {
197
205
  Each middleware class under `app/middleware/` is registered as a **named group** under its class
198
206
  name, so routes can reference it by string without importing it:
199
207
 
200
- ```typescript
208
+ ```typescript fragment
201
209
  // app/middleware/EnsureSubscribed.ts
202
210
  import { BaseMiddleware } from "zerotal";
203
211
 
@@ -208,7 +216,7 @@ export class EnsureSubscribed extends BaseMiddleware<{}> {
208
216
  }
209
217
  ```
210
218
 
211
- ```typescript
219
+ ```typescript fragment
212
220
  // routes/web.ts — reference by class name
213
221
  Router.group({ middleware: ["EnsureSubscribed"] }, () => {
214
222
  /* … */
@@ -218,7 +226,7 @@ Router.group({ middleware: ["EnsureSubscribed"] }, () => {
218
226
  Middleware is **not** applied globally by default. Opt a class into the global pipeline with
219
227
  `static global = true`:
220
228
 
221
- ```typescript
229
+ ```typescript fragment
222
230
  // app/middleware/RequestId.ts
223
231
  export class RequestId extends BaseMiddleware<{}> {
224
232
  static global = true; // runs on every request
@@ -281,7 +289,7 @@ export class NotifyFollowersJob extends Job {
281
289
 
282
290
  Dispatch it from anywhere without importing the class into a barrel:
283
291
 
284
- ```typescript
292
+ ```typescript fragment
285
293
  // in a controller
286
294
  await Queue.dispatch(new NotifyFollowersJob(post.id));
287
295
  ```
@@ -296,7 +304,7 @@ registered with the scheduler at boot. Each declares its cadence (a `cron` strin
296
304
  `frequency()` method) and its work (`handle()`); the loader translates the class's declarative
297
305
  settings into a scheduled task.
298
306
 
299
- ```typescript
307
+ ```typescript fragment
300
308
  // app/schedules/SendDailyReports.ts
301
309
  import { Schedule } from "@zerotal/scheduler";
302
310
 
@@ -342,7 +350,7 @@ import only runs because this concern imports it.
342
350
  Once models are registered at boot, Zerotal can sync the schema additively — create missing tables
343
351
  and add missing columns to match your models (TypeORM-style `synchronize`):
344
352
 
345
- ```typescript
353
+ ```typescript fragment
346
354
  // config/database.ts
347
355
  import { DatabaseConfig } from "@zerotal/orm";
348
356
 
@@ -413,7 +421,7 @@ let you relocate any concern. Files starting with `_` and `*.test.ts` / `*.test.
413
421
  Discovery is extensible. A provider can contribute its own concern descriptor via
414
422
  `app.registerConcern(...)`:
415
423
 
416
- ```typescript
424
+ ```typescript fragment
417
425
  // app/providers/AppServiceProvider.ts — inside onRegister/onBooting
418
426
  this.app.registerConcern({
419
427
  name: "validators",
package/docs/cookies.md CHANGED
@@ -28,7 +28,7 @@ When you need to read or set a raw cookie directly, you work with the standard
28
28
 
29
29
  Cookies arrive in the request's `Cookie` header. Read and parse it from the context:
30
30
 
31
- ```ts
31
+ ```ts fragment
32
32
  // in a controller
33
33
  import type { HttpContext } from "zerotal";
34
34
 
@@ -49,7 +49,7 @@ const theme = readCookie(ctx, "theme") ?? "light";
49
49
  Set the response with a `ctx` helper, then append a `Set-Cookie` header to it. Use
50
50
  `append` (not `set`) so multiple cookies can be sent in one response:
51
51
 
52
- ```ts
52
+ ```ts fragment
53
53
  // in a controller
54
54
  ctx.json({ ok: true }); // assigns ctx.response
55
55
 
@@ -75,7 +75,7 @@ ctx.response!.headers.append(
75
75
 
76
76
  To **delete** a cookie, set it again with `Max-Age=0`:
77
77
 
78
- ```ts
78
+ ```ts fragment
79
79
  // in a controller
80
80
  ctx.response!.headers.append("Set-Cookie", "theme=; Path=/; Max-Age=0");
81
81
  ```
package/docs/csrf.md CHANGED
@@ -55,7 +55,7 @@ Register `CsrfMiddleware` **after** `SessionMiddleware` — it reads and writes
55
55
  session. In production over HTTPS, set `secure: true` so the cookie carries the
56
56
  `Secure` flag:
57
57
 
58
- ```typescript
58
+ ```typescript fragment
59
59
  // bootstrap/app.ts
60
60
  import { SessionMiddleware, CsrfMiddleware } from "@zerotal/session";
61
61
 
@@ -89,7 +89,7 @@ on every mutating request.
89
89
  Expose the token server-side and send it in the header (or a hidden field your
90
90
  handler reads). `CsrfMiddleware.token()` returns the current session's token:
91
91
 
92
- ```typescript
92
+ ```typescript fragment
93
93
  // in an Inertia shared-props factory
94
94
  import { CsrfMiddleware } from "@zerotal/session";
95
95
 
@@ -99,7 +99,7 @@ Inertia.share({ csrf_token: () => CsrfMiddleware.token() });
99
99
  // <meta name="csrf-token" content="${CsrfMiddleware.token()}">
100
100
  ```
101
101
 
102
- ```typescript
102
+ ```typescript fragment
103
103
  // client-side fetch
104
104
  await fetch("/posts", {
105
105
  method: "POST",
@@ -140,7 +140,7 @@ is the rare feature whose test is mostly about proving a request **fails**.
140
140
  worth asserting by number — a `403` in this test means your authorization denied
141
141
  the request and CSRF never ran:
142
142
 
143
- ```typescript
143
+ ```typescript fragment
144
144
  // tests/http/csrf.test.ts
145
145
  import { test } from "bun:test";
146
146
  import { createApp } from "../helpers.ts";
@@ -159,7 +159,7 @@ test("an unsafe request without a token is refused", async () => {
159
159
  that is the only request in the test, the middleware could be absent entirely and
160
160
  the suite would stay green:
161
161
 
162
- ```typescript
162
+ ```typescript fragment
163
163
  // tests/http/csrf.test.ts
164
164
  test("GET is never challenged", async () => {
165
165
  (await app.get("/posts")).assertOk(); // passes with or without CSRF — not a CSRF test
@@ -169,7 +169,7 @@ test("GET is never challenged", async () => {
169
169
  To test the **success** path, seed the session with a token you choose and send
170
170
  the same value on the header the middleware reads:
171
171
 
172
- ```typescript
172
+ ```typescript fragment
173
173
  // tests/http/csrf.test.ts
174
174
  const token = "test-csrf-token";
175
175
 
package/docs/database.md CHANGED
@@ -56,7 +56,9 @@ import { env } from "zerotal";
56
56
  export default DatabaseConfig({
57
57
  driver: "postgres",
58
58
  url: env("DATABASE_URL", "./database/db.sqlite"),
59
- replicas: [env("REPLICA_1_URL"), env("REPLICA_2_URL")],
59
+ // `env()` with no fallback is `string | undefined`, and an unset replica should
60
+ // drop out rather than become an empty connection string.
61
+ replicas: [env("REPLICA_1_URL"), env("REPLICA_2_URL")].filter((url) => url !== undefined),
60
62
  });
61
63
  ```
62
64
 
@@ -98,7 +100,7 @@ Three styles, in order of preference:
98
100
 
99
101
  Pass a callback to `DB.transaction()`. Bun commits on resolve and rolls back on throw — you never call commit/rollback manually:
100
102
 
101
- ```typescript
103
+ ```typescript fragment
102
104
  // in a controller or service
103
105
  import { DB } from "@zerotal/orm";
104
106
 
@@ -120,7 +122,7 @@ await DB.transaction(async (trx) => {
120
122
 
121
123
  All `Model` and `DB` queries made inside the callback automatically use the transaction connection via `AsyncLocalStorage` — you don't need to pass `trx` explicitly unless you're mixing raw `DB.table()` calls with model calls:
122
124
 
123
- ```typescript
125
+ ```typescript fragment
124
126
  // in a service
125
127
  await DB.transaction(async () => {
126
128
  // These automatically use the transaction without passing trx:
@@ -136,7 +138,7 @@ await DB.transaction(async () => {
136
138
 
137
139
  Nested `DB.transaction()` calls automatically use SAVEPOINTs. An inner throw rolls back only the inner block, not the entire outer transaction:
138
140
 
139
- ```typescript
141
+ ```typescript fragment
140
142
  // in a service
141
143
  await DB.transaction(async () => {
142
144
  await Order.create({ userId, total });
@@ -159,7 +161,7 @@ await DB.transaction(async () => {
159
161
 
160
162
  Pass the number of attempts as a second argument to retry automatically on deadlock or serialization failures:
161
163
 
162
- ```typescript
164
+ ```typescript fragment
163
165
  // in a service
164
166
  await DB.transaction(async () => {
165
167
  // critical concurrent write
@@ -208,7 +210,7 @@ chain handles all four CRUD operations: call a read terminal like `get()` to fet
208
210
  rows, or a write method like `insert()`, `update()`, or `delete()` to change them.
209
211
  Values are always parameterised for you, so there's no injection risk:
210
212
 
211
- ```typescript
213
+ ```typescript fragment
212
214
  // in a controller or service
213
215
  import { DB } from "@zerotal/orm";
214
216
 
@@ -237,7 +239,7 @@ Use when the query builder doesn't cover what you need.
237
239
 
238
240
  > **Danger** — Always parameterise values; never interpolate them directly into the SQL string. String interpolation opens a SQL injection hole.
239
241
 
240
- ```typescript
242
+ ```typescript fragment
241
243
  // in a service
242
244
  import { DB } from "@zerotal/orm";
243
245
 
@@ -260,7 +262,7 @@ const [{ version }] = await DB.raw<{ version: string }>("SELECT version()");
260
262
 
261
263
  `whereJson` takes the column and a JSON path joined with `->`, then the value to match:
262
264
 
263
- ```typescript
265
+ ```typescript fragment
264
266
  // in a service
265
267
  // Equivalent SQL: WHERE meta->>'notifications.email' = ?
266
268
  await DB.table("settings").whereJson("meta->notifications.email", true).get();
@@ -279,8 +281,10 @@ import { DatabaseConfig } from "@zerotal/orm";
279
281
  import { env } from "zerotal";
280
282
 
281
283
  export default DatabaseConfig({
282
- url: env("DATABASE_URL"),
283
- replicas: [env("REPLICA_1_URL"), env("REPLICA_2_URL")],
284
+ url: env("DATABASE_URL", "./database/db.sqlite"),
285
+ // `env()` with no fallback is `string | undefined`, and an unset replica should
286
+ // drop out rather than become an empty connection string.
287
+ replicas: [env("REPLICA_1_URL"), env("REPLICA_2_URL")].filter((url) => url !== undefined),
284
288
  });
285
289
  ```
286
290
 
@@ -296,7 +300,7 @@ No model code changes needed. Routing is automatic:
296
300
 
297
301
  After a write, the replica may lag. Use `DB.onPrimary()` when you need to read the just-written data immediately:
298
302
 
299
- ```typescript
303
+ ```typescript fragment
300
304
  // in a controller
301
305
  const post = await Post.create({ title: "Hello", userId });
302
306
 
@@ -327,7 +331,7 @@ that did not exist. `NPlusOneError.distinctArgs` carries the count if you want t
327
331
  Tune the detector once at boot. Lower the `threshold` to catch leaks sooner, and set
328
332
  `mode: "throw"` in CI so an N+1 query fails the test suite instead of just logging:
329
333
 
330
- ```typescript
334
+ ```typescript fragment
331
335
  // bootstrap/app.ts — or a service provider
332
336
  DB.preventNPlusOne({
333
337
  threshold: 3, // warn after 3 repetitions instead of 5
@@ -341,7 +345,7 @@ Some repetition is intentional — a polling endpoint, an audit log — and you
341
345
  the detector crying wolf. Call `allowNPlusOne` to silence a specific table, either for
342
346
  good or just for the current request:
343
347
 
344
- ```typescript
348
+ ```typescript fragment
345
349
  // in a service provider or request handler
346
350
  // Suppress permanently for a table/pattern
347
351
  DB.allowNPlusOne("activity_logs");
@@ -373,7 +377,7 @@ With the connection registered, set `static connection` on any model that should
373
377
  there. From then on every query that model makes — reads, writes, pagination — is routed
374
378
  to that connection with no extra arguments:
375
379
 
376
- ```typescript
380
+ ```typescript fragment
377
381
  // app/models/AnalyticsEvent.ts
378
382
  export class AnalyticsEvent extends Model {
379
383
  static connection = "analytics";
@@ -400,7 +404,7 @@ Connection resolution priority (highest to lowest):
400
404
 
401
405
  Use advisory locks for application-level mutual exclusion — e.g. preventing two workers from processing the same job simultaneously:
402
406
 
403
- ```typescript
407
+ ```typescript fragment
404
408
  // in a job or worker
405
409
  import { DB } from "@zerotal/orm";
406
410
 
@@ -435,9 +439,10 @@ from a service provider:
435
439
  For example, subscribe to `QueryExecuted` in a provider to surface slow queries in your
436
440
  logs — the handler receives the SQL, its bindings, and how long it took:
437
441
 
438
- ```typescript
442
+ ```typescript fragment
439
443
  // in a service provider
440
- import { FrameworkEvents, QueryExecuted } from "zerotal";
444
+ import { FrameworkEvents } from "zerotal";
445
+ import { QueryExecuted } from "@zerotal/orm";
441
446
 
442
447
  // Log slow queries
443
448
  FrameworkEvents.on(QueryExecuted, (e) => {
@@ -71,6 +71,30 @@ Each entry is a `DeployTarget`:
71
71
  Omit the file entirely and you get `DEFAULT_DEPLOY_TARGETS`: `production` and
72
72
  `staging`, both with the default steps.
73
73
 
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:
78
+
79
+ ```ts
80
+ // config/deploy.ts
81
+ export default {
82
+ targets: {
83
+ production: {
84
+ url: "https://example.com",
85
+ steps: ["release:check", "assets:build", "inertia:build", "migrate"],
86
+ },
87
+ },
88
+ };
89
+ ```
90
+
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.
93
+
94
+ Two things worth adding while you are there: `assets:build` and `inertia:build` accept
95
+ `--clean`, which removes anything in the output directory the build did not write — see
96
+ [Build assets](#build-assets).
97
+
74
98
  > **Note** — `deploy:<env>` runs **where the app runs**, with that environment's
75
99
  > variables. It does not reach another machine over SSH. Run it on the box, or in
76
100
  > the container build, as the step before the restart.
@@ -176,6 +200,56 @@ release that built its assets ahead of time and locked the tree down serves what
176
200
  shipped, and logs one line saying so. That is what lets the service run under a properly
177
201
  hardened unit; see [Hardening the service](#hardening-the-service).
178
202
 
203
+ ### Replace the asset directory on release, do not merge into it
204
+
205
+ Code splitting names every chunk after its content, so each build emits a new set and
206
+ abandons the last. Both build commands clean those up as they go, and they do it without
207
+ needing anything to have survived from the previous build — so the directory a build
208
+ writes holds that build's output and nothing else, on a developer's machine and a fresh CI
209
+ checkout alike.
210
+
211
+ What the build cannot clean is a directory it never sees. A release that is **unpacked over
212
+ the top** of the running one — `tar -xzf` into the app directory, `rsync` without
213
+ `--delete` — merges: every file in the archive is written, and every file that is not in
214
+ the archive is left exactly where it was. Nothing on that server ever ran a build, so
215
+ nothing ever removes last release's chunks, and they collect one release at a time.
216
+
217
+ That is not only clutter. They stay publicly fetchable at their content-hashed URLs, so a
218
+ page whose copy you withdrew is still readable by anyone holding the link — pricing you
219
+ took down, a policy you replaced, a feature you pulled.
220
+
221
+ Clear the directory as part of the release, before the new files land:
222
+
223
+ ```bash
224
+ # on the server, before extracting
225
+ rm -rf "$APP_DIR/public/assets"
226
+ tar -xzf release.tgz -C "$APP_DIR"
227
+
228
+ # or let rsync do it
229
+ rsync -a --delete public/assets/ "$HOST:$APP_DIR/public/assets/"
230
+ ```
231
+
232
+ Ordering matters if the old release is still serving traffic: clearing the directory takes
233
+ its bundles away, so do it as close to the swap as you can, or stage the release in a new
234
+ directory and move it into place.
235
+
236
+ ### `--clean` for a directory the build does not own outright
237
+
238
+ The cleanup above recognises the filenames `Bun.build()` produces. An app that sets its own
239
+ `naming`, or that writes a second bundle into the same directory by other means, can leave
240
+ output the build does not recognise as its own. `--clean` needs no recognition — whatever
241
+ this build did not write, goes:
242
+
243
+ ```bash
244
+ # in your project root
245
+ bun zt assets:build --clean
246
+ bun zt inertia:build --production --clean
247
+ ```
248
+
249
+ It refuses `public/` itself and the project root, where deleting what was not rebuilt would
250
+ take the app's images and favicon with it. Point the build at a directory of its own —
251
+ `public/assets` is the Inertia default.
252
+
179
253
  Bump your asset version (or hash the bundle) so clients reload onto the new build — see
180
254
  [Inertia › Asset versioning](/docs/inertia/middleware#asset-versioning).
181
255
 
@@ -228,7 +302,7 @@ which behind a proxy is the loopback address it bound to (`http://127.0.0.1:3002
228
302
  the public URL the browser sends. So the public origin has to come from config, and it
229
303
  does: `AppConfig()` fills `app.allowedOrigins` from `url`.
230
304
 
231
- ```ts
305
+ ```ts fragment
232
306
  // config/app.ts
233
307
  export default AppConfig({
234
308
  name: "My App",
@@ -240,7 +314,7 @@ Name additional origins only when a genuinely different host drives the app —
240
314
  `app.example.com` calling `api.example.com`. What you pass is added to the URL's origin
241
315
  rather than replacing it:
242
316
 
243
- ```ts
317
+ ```ts fragment
244
318
  // config/app.ts
245
319
  export default AppConfig({
246
320
  url: env("APP_URL"),
@@ -253,6 +327,29 @@ Origins are compared exactly: no wildcards and no suffix matching, because
253
327
 
254
328
  > **Warning** — an app with the wrong origin configured renders every page correctly and refuses every action. There is no 500, nothing in the logs, and a status-code health check passes. The only symptom is that buttons do nothing.
255
329
 
330
+ ### Rate limiting counts the proxy, not the visitor
331
+
332
+ `ThrottleMiddleware` identifies a client by the address the request arrived from. Behind a
333
+ proxy that address is the proxy — `127.0.0.1` for every visitor on the site — so one bucket
334
+ is shared by everybody, and a single busy client can lock the whole site out.
335
+
336
+ Tell it how many proxies sit in front of the app:
337
+
338
+ ```ts fragment
339
+ // One proxy (Caddy, nginx, a load balancer) between the internet and the app.
340
+ ThrottleMiddleware.with({ maxAttempts: 60, trustedProxies: 1 });
341
+ ```
342
+
343
+ The count is how many entries to skip from the right of `X-Forwarded-For`. It is opt-in and
344
+ defaults to zero because the header is written by the client until something trusted
345
+ overwrites it: trusting it by default would let anyone forge an address and walk around
346
+ every limit you set. Zero is the safe default and the wrong answer once you deploy behind
347
+ something — which is easy to do and never revisit, because nothing about it fails loudly.
348
+ The symptom is a legitimate visitor getting a 429 they did not earn.
349
+
350
+ Count the proxies you actually run. Setting `trustedProxies: 3` with one proxy in front
351
+ reads an entry the client supplied.
352
+
256
353
  ### Never gate the transport path
257
354
 
258
355
  **Browsers do not attach basic-auth credentials to a WebSocket handshake.** An HTTP auth
package/docs/devtools.md CHANGED
@@ -57,7 +57,7 @@ lifecycle order, all of them additionally short-circuited in production):
57
57
  > **Note** — Nothing happens at import time. The trace store opens its database
58
58
  > on first use rather than in a constructor, so importing this package in an app
59
59
  > that runs in production writes nothing and starts no timer. N+1 detection
60
- > itself is owned by the [ORM](/docs/orm/index) provider (and env-gated there);
60
+ > itself is owned by the [ORM](/docs/orm) provider (and env-gated there);
61
61
  > DevTools only consumes the `NPlusOneDetected` event and surfaces it.
62
62
 
63
63
  ## Start the client panel
@@ -100,7 +100,7 @@ after works the way you would expect.
100
100
 
101
101
  `DevTools.start()` accepts three optional fields:
102
102
 
103
- ```typescript
103
+ ```typescript fragment
104
104
  // resources/js/app.js
105
105
  DevTools.start({ endpoint: "/__zerotal/devtools", mode: "floating" }); // defaults
106
106
  ```
@@ -356,7 +356,7 @@ log line's, a stack frame. Going from "this query is slow" to the line that ran
356
356
  it is the most frequent move in a debugging session, and without this it is two
357
357
  manual searches.
358
358
 
359
- ```typescript
359
+ ```typescript fragment
360
360
  // config/devtools.ts
361
361
  export default DevtoolsConfig({
362
362
  editor: "cursor", // vscode | vscode-insiders | cursor | windsurf | zed | webstorm
@@ -369,7 +369,7 @@ Set `editor: null` to render locations as plain text instead.
369
369
  the one with your editor on it — a container reports `/app/src/Foo.ts` for a file
370
370
  that lives at `~/project/src/Foo.ts`. Map it home:
371
371
 
372
- ```typescript
372
+ ```typescript fragment
373
373
  export default DevtoolsConfig({
374
374
  editorPathMap: { "/app": "/Users/you/project" },
375
375
  });
@@ -425,7 +425,7 @@ things that look like secrets. `console.log(user)` is masked field by field;
425
425
 
426
426
  Open individual names back up, close extra ones, or turn it off entirely:
427
427
 
428
- ```typescript
428
+ ```typescript fragment
429
429
  // config/devtools.ts
430
430
  export default DevtoolsConfig({
431
431
  redact: {
@@ -458,7 +458,7 @@ directly when you are recording values somewhere else and need the same three
458
458
  problems solved — cycles, a depth bound, and values like `Date` or `File` that
459
459
  read better flat than walked — but want your own markers:
460
460
 
461
- ```typescript
461
+ ```typescript fragment
462
462
  import { redactGraph } from "@zerotal/core/security";
463
463
 
464
464
  const safe = redactGraph(payload, {
@@ -557,7 +557,7 @@ the inspector on a shared staging box was to lie about `APP_ENV`.
557
557
 
558
558
  There is now a supported way:
559
559
 
560
- ```typescript
560
+ ```typescript fragment
561
561
  // config/devtools.ts
562
562
  export default DevtoolsConfig({
563
563
  enabled: true, // explicit; `null` follows the dev-surface gate
@@ -589,7 +589,7 @@ there the way in is the dashboard at `/__zerotal/devtools`.
589
589
 
590
590
  To switch it off entirely without removing the provider:
591
591
 
592
- ```typescript
592
+ ```typescript fragment
593
593
  // config/devtools.ts
594
594
  export default DevtoolsConfig({ enabled: false });
595
595
  ```
@@ -618,7 +618,7 @@ the way — not about the panel itself.
618
618
  injection middleware appends a script tag to every HTML response, and a
619
619
  misconfigured deploy that ships it exposes request traces to your users:
620
620
 
621
- ```typescript
621
+ ```typescript fragment
622
622
  // tests/devtools/injection.test.ts
623
623
  import { test } from "bun:test";
624
624
  import { createApp } from "../helpers.ts";
@@ -637,7 +637,7 @@ test("the devtools script is not injected outside development", async () => {
637
637
  to a JSON body or a file download corrupts it, and the failure shows up as a
638
638
  parse error somewhere unrelated:
639
639
 
640
- ```typescript
640
+ ```typescript fragment
641
641
  // tests/devtools/injection.test.ts
642
642
  test("JSON responses are left alone", async () => {
643
643
  const res = await app.get("/api/posts", { Accept: "application/json" });
@@ -650,7 +650,7 @@ test("JSON responses are left alone", async () => {
650
650
  **The trace store is an ordinary object**, so a panel plugin you write tests
651
651
  without a browser:
652
652
 
653
- ```typescript
653
+ ```typescript fragment
654
654
  // tests/devtools/plugin.test.ts
655
655
  import { traceStore } from "@zerotal/devtools";
656
656
 
@@ -753,7 +753,7 @@ leaves. Each node's own fields become its `treeBadge` chip, its `flags`, and a
753
753
  dim attribute line, so you describe what a node _is_ without the panel knowing
754
754
  what any of it means:
755
755
 
756
- ```typescript
756
+ ```typescript fragment
757
757
  trace.channel({
758
758
  id: "widgets",
759
759
  label: "Widgets",
@@ -779,7 +779,7 @@ every value any package might use.
779
779
 
780
780
  The shape pushed to the store and streamed to the panel:
781
781
 
782
- ```typescript
782
+ ```typescript fragment
783
783
  // from @zerotal/devtools
784
784
  interface RequestTrace {
785
785
  id: string;
@@ -903,7 +903,7 @@ Resolve `devtools.trace` from the container, declare how your entries should
903
903
  read, then record one per event. Guard the lookup: it is absent when devtools is
904
904
  not installed or the app is in production, and your package must not care.
905
905
 
906
- ```typescript
906
+ ```typescript fragment
907
907
  // your-package/src/observability.ts
908
908
  import { FrameworkEvents, RequestContext } from "zerotal";
909
909
  import type { Application } from "zerotal";
@@ -955,7 +955,7 @@ and entries recorded before a channel is declared still appear once it is.
955
955
  From your package's **browser** code, register a panel on the global registry the
956
956
  panel exposes:
957
957
 
958
- ```ts
958
+ ```ts fragment
959
959
  window.__zerotalDevtools?.register({
960
960
  id: "my-panel", // unique — the tab is addressed internally as `plugin:my-panel`
961
961
  title: "My Panel", // tab label
@@ -1010,4 +1010,4 @@ folded follow-up (`child`).
1010
1010
  - [Query builder](/docs/query-builder) — the queries DevTools traces and flags for N+1.
1011
1011
  - [Events](/docs/events) — the `FrameworkEvents` that DevTools subscribes to.
1012
1012
  - [Telemetry](/docs/telemetry) — production-grade metrics once you move past the dev panel.
1013
- - [Testing](/docs/testing/index) — assert on requests without the floating panel.
1013
+ - [Testing](/docs/testing) — assert on requests without the floating panel.