@zerotal/arch 1.7.3 → 1.7.5

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 (127) hide show
  1. package/CHANGELOG.md +34 -2
  2. package/README.md +4 -2
  3. package/api-surface.md +4 -4
  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 +165 -1
  32. package/docs/client/index.md +277 -70
  33. package/docs/commands.md +4 -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 +46 -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 +2 -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 +199 -0
  55. package/docs/flow/index.md +20 -20
  56. package/docs/flow/layouts.md +35 -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 +12 -7
  70. package/docs/inertia/props.md +22 -22
  71. package/docs/inertia/rendering.md +86 -7
  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 +36 -8
  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 +3 -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/index.ts +25 -1
  120. package/src/tools/searchDocs.ts +7 -0
  121. package/docs/client/auth.md +0 -113
  122. package/docs/client/errors.md +0 -139
  123. package/docs/client/files.md +0 -118
  124. package/docs/client/references.md +0 -58
  125. package/docs/client/requests.md +0 -131
  126. package/docs/client/resilience.md +0 -141
  127. package/docs/client/testing.md +0 -146
package/docs/seeding.md CHANGED
@@ -57,7 +57,7 @@ Put your inserts here — create records through your models or factories exactl
57
57
  would in application code. Because seeders run through the ORM, casts, hooks, and
58
58
  relationships all behave normally:
59
59
 
60
- ```ts
60
+ ```ts fragment
61
61
  // database/seeders/UserSeeder.ts
62
62
  import { Seeder } from "@zerotal/orm";
63
63
  import { User } from "../../app/models/User.ts";
@@ -83,7 +83,7 @@ failure still rolls back independently. Where there is no database connection
83
83
  bound at all — a seeder that writes fixtures to disk, say — the run is left
84
84
  alone rather than failing for want of a transaction it never needed.
85
85
 
86
- ```ts
86
+ ```ts fragment
87
87
  // database/seeders/DatabaseSeeder.ts
88
88
  import { Seeder, DB } from "@zerotal/orm";
89
89
  import { UserSeeder } from "./UserSeeder.ts";
@@ -133,7 +133,7 @@ yourself (`await new UserSeeder().run()`) to set up just the rows that test need
133
133
  [Factories](/docs/orm/factories) are the cleanest way to generate seed records.
134
134
  Define them once, then call them from a seeder's `run()`:
135
135
 
136
- ```ts
136
+ ```ts fragment
137
137
  // database/seeders/PostSeeder.ts
138
138
  import { Seeder } from "@zerotal/orm";
139
139
  import { UserFactory } from "../factories/UserFactory.ts";
@@ -165,7 +165,7 @@ helper.
165
165
  Set your suite up once as described in [Testing](/docs/testing). A seeder is a
166
166
  class with a `run()` method, so testing one is just calling it:
167
167
 
168
- ```typescript
168
+ ```typescript fragment
169
169
  // tests/seeders/RoleSeeder.test.ts
170
170
  import { test } from "bun:test";
171
171
  import { assertDatabaseCount, assertDatabaseHas } from "@zerotal/testing";
@@ -183,7 +183,7 @@ test("seeds the three baseline roles", async () => {
183
183
  a deploy runs on every release — must not double its rows. This is the failure
184
184
  seeders actually have:
185
185
 
186
- ```typescript
186
+ ```typescript fragment
187
187
  // tests/seeders/RoleSeeder.test.ts
188
188
  test("running twice does not duplicate rows", async () => {
189
189
  await new RoleSeeder().run();
@@ -199,7 +199,7 @@ If that fails, the seeder needs `updateOrCreate` rather than `create`.
199
199
  seeder can assert the all-or-nothing behaviour directly — make a late child throw
200
200
  and check that the earlier one left nothing behind:
201
201
 
202
- ```typescript
202
+ ```typescript fragment
203
203
  // tests/seeders/DatabaseSeeder.test.ts
204
204
  test("a failing child rolls the whole run back", async () => {
205
205
  await expect(new DatabaseSeeder().run()).rejects.toThrow();
package/docs/session.md CHANGED
@@ -113,7 +113,7 @@ registered. Both `ctx.session` and `Session` expose the same methods (see the
113
113
 
114
114
  ## Reading and writing
115
115
 
116
- ```typescript
116
+ ```typescript fragment
117
117
  // in a controller
118
118
  import type { HttpContext } from "zerotal";
119
119
 
@@ -149,7 +149,7 @@ Flash stores a value for **one subsequent request only** — it is automatically
149
149
  swept after the next request. The canonical use case is POST-Redirect-GET status
150
150
  messages.
151
151
 
152
- ```typescript
152
+ ```typescript fragment
153
153
  // in a controller — survives exactly one redirect
154
154
  ctx.session.flash("success", "Post created!");
155
155
  ctx.session.flash("errors", { title: ["Required"] });
@@ -164,7 +164,7 @@ Session.flash("success", "Post created!");
164
164
 
165
165
  Read on the next request:
166
166
 
167
- ```typescript
167
+ ```typescript fragment
168
168
  // in a controller
169
169
  const msg = ctx.flashed<string>("success");
170
170
  const errors = ctx.flashed<Record<string, string[]>>("errors");
@@ -172,7 +172,7 @@ const errors = ctx.flashed<Record<string, string[]>>("errors");
172
172
 
173
173
  Full POST-Redirect-GET pattern:
174
174
 
175
- ```typescript
175
+ ```typescript fragment
176
176
  // in a controller
177
177
  async store(ctx: HttpContext) {
178
178
  await Post.create(await ctx.body());
@@ -191,7 +191,7 @@ async index(ctx: HttpContext) {
191
191
  Always regenerate the session ID after a privilege change (login, logout, password
192
192
  change) to prevent session-fixation attacks:
193
193
 
194
- ```typescript
194
+ ```typescript fragment
195
195
  // in a controller
196
196
  ctx.session.regenerate(); // issues a new session ID; data is preserved
197
197
  // or: Session.regenerate();
@@ -228,7 +228,9 @@ import { SessionConfig } from "@zerotal/session";
228
228
  import { env } from "zerotal";
229
229
 
230
230
  export default SessionConfig({
231
- driver: env("SESSION_DRIVER", "cookie"),
231
+ // One of 'cookie' | 'redis', written literally: the field is that union and
232
+ // `env()` returns a plain string.
233
+ driver: "cookie",
232
234
  // …
233
235
  });
234
236
  ```
@@ -243,7 +245,7 @@ export default SessionConfig({
243
245
  Unlike `SessionMiddleware`, it is **not** auto-registered — add it after the session
244
246
  middleware, typically in the `web` middleware group:
245
247
 
246
- ```typescript
248
+ ```typescript fragment
247
249
  // bootstrap/app.ts (or wherever you register middleware)
248
250
  import { SessionMiddleware, CsrfMiddleware } from "@zerotal/session";
249
251
 
@@ -279,7 +281,7 @@ it as a header:
279
281
  <meta name="csrf-token" content="${CsrfMiddleware.token()}" />
280
282
  ```
281
283
 
282
- ```typescript
284
+ ```typescript fragment
283
285
  // in your client JS
284
286
  const token = document.querySelector('meta[name="csrf-token"]')?.getAttribute("content");
285
287
 
@@ -326,7 +328,7 @@ session itself prefer the facade.
326
328
  reads `session.get("user_id")`, looks the user up with a callback you supply, and
327
329
  attaches the result to `ctx.user` for the rest of the request.
328
330
 
329
- ```typescript
331
+ ```typescript fragment
330
332
  // bootstrap/app.ts
331
333
  import { AuthSessionMiddleware, CookieDriver } from "@zerotal/session";
332
334
  import { env } from "zerotal";
@@ -346,7 +348,7 @@ app.use(
346
348
  Pre-seed session data with `withSession()`, then assert on the response with
347
349
  `assertSessionHas()`:
348
350
 
349
- ```typescript
351
+ ```typescript fragment
350
352
  // in a test
351
353
  import { createTestApp } from "@zerotal/testing";
352
354
 
@@ -355,7 +357,7 @@ const res = await testApp.withSession({ locale: "fr", cart: [1, 2, 3] }).get("/c
355
357
  res.assertOk();
356
358
  ```
357
359
 
358
- ```typescript
360
+ ```typescript fragment
359
361
  // in a test
360
362
  const res = await testApp.post("/locale", { locale: "fr" });
361
363
 
@@ -363,7 +365,7 @@ res.assertSessionHas("locale");
363
365
  res.assertSessionHas("locale", "fr");
364
366
  ```
365
367
 
366
- ```typescript
368
+ ```typescript fragment
367
369
  // in a test — assert a flash key was written before a redirect
368
370
  const res = await testApp.actingAs(user).post("/posts", { title: "Hello" });
369
371
 
@@ -424,7 +426,7 @@ stores the whole session in the cookie itself, so putting a user object — or a
424
426
  flash message with a stack trace — into the session overflows a hard browser
425
427
  limit:
426
428
 
427
- ```typescript
429
+ ```typescript fragment
428
430
  // in a controller
429
431
  import { SessionCookieOverflowError } from "@zerotal/session";
430
432
 
@@ -433,7 +435,7 @@ try {
433
435
  } catch (error) {
434
436
  if (error instanceof SessionCookieOverflowError) {
435
437
  // Keep a key in the session, the payload somewhere with room.
436
- const id = await Cache.put(hugeObject);
438
+ const id = await Cache.set(hugeObject);
437
439
  http.session.put("reportId", id);
438
440
  } else throw error;
439
441
  }
package/docs/social.md CHANGED
@@ -96,7 +96,7 @@ Each provider entry accepts the following fields:
96
96
  The driver handles CSRF state, session storage, and code extraction. Your
97
97
  controller is a few lines per action — read the provider from `ctx.params`:
98
98
 
99
- ```typescript
99
+ ```typescript fragment
100
100
  // app/controllers/SocialController.ts
101
101
  import { Social } from "@zerotal/auth";
102
102
  import type { HttpContext } from "zerotal";
@@ -148,7 +148,7 @@ mechanism used by `@zerotal/flow` (`Router.flow`) and `@zerotal/inertia`
148
148
  (`Router.inertia`). Once `SocialProvider` is in your bootstrap, the macro is
149
149
  available in every route file with no additional import:
150
150
 
151
- ```typescript
151
+ ```typescript fragment
152
152
  // routes/auth.ts
153
153
  import { Router } from "zerotal";
154
154
  import { SocialController } from "../app/controllers/SocialController.ts";
@@ -162,7 +162,7 @@ Router.social("/auth", SocialController);
162
162
 
163
163
  Or register routes individually for full control:
164
164
 
165
- ```typescript
165
+ ```typescript fragment
166
166
  // routes/auth.ts
167
167
  import { Router } from "zerotal";
168
168
  import { SocialController } from "../app/controllers/SocialController.ts";
@@ -196,7 +196,7 @@ call the provider's API on the user's behalf later.
196
196
 
197
197
  Default scopes: `read:user user:email`.
198
198
 
199
- ```typescript
199
+ ```typescript fragment
200
200
  // config/social.ts
201
201
  github: {
202
202
  clientId: env("GITHUB_CLIENT_ID", ""),
@@ -218,7 +218,7 @@ primary email before returning `SocialUser` — no extra code in your controller
218
218
 
219
219
  Default scopes: `openid profile email`.
220
220
 
221
- ```typescript
221
+ ```typescript fragment
222
222
  // config/social.ts
223
223
  google: {
224
224
  clientId: env("GOOGLE_CLIENT_ID", ""),
@@ -250,7 +250,7 @@ Sign in with Apple has three quirks — all handled internally by `AppleDriver`:
250
250
  credentials and the driver signs the JWT automatically using the Web Crypto API.
251
251
  No extra dependency needed:
252
252
 
253
- ```typescript
253
+ ```typescript fragment
254
254
  // config/social.ts
255
255
  apple: {
256
256
  clientId: 'com.myapp.service', // your Service ID
@@ -264,7 +264,7 @@ apple: {
264
264
  **Option B — pre-sign the JWT yourself** (e.g. with `apple-signin-auth`) and pass
265
265
  it as `clientSecret`. Useful if you rotate the JWT externally:
266
266
 
267
- ```typescript
267
+ ```typescript fragment
268
268
  // config/social.ts
269
269
  apple: {
270
270
  clientId: 'com.myapp.service',
@@ -291,7 +291,7 @@ auto-registered from `config/social.ts` by their key — just supply credentials
291
291
  | `linkedin` | `LinkedInDriver` | `openid profile email` | OpenID Connect userinfo (`sub` is the id). |
292
292
  | `gitlab` | `GitLabDriver` | `read_user` | Targets gitlab.com. |
293
293
 
294
- ```typescript
294
+ ```typescript fragment
295
295
  // config/social.ts — same shape as github/google/apple
296
296
  discord: {
297
297
  clientId: env("DISCORD_CLIENT_ID", ""),
@@ -310,7 +310,7 @@ Scopes can be set per-provider in `config/social.ts`, but you can also add or
310
310
  replace them fluently at redirect time. `.scopes()` merges with the configured /
311
311
  default scopes; `.setScopes()` replaces them outright:
312
312
 
313
- ```typescript
313
+ ```typescript fragment
314
314
  // Ask for extra GitHub scopes on top of the defaults
315
315
  Social.driver("github").scopes(["repo", "read:org"]).redirect();
316
316
 
@@ -322,7 +322,7 @@ Use `.with()` to append provider-specific query parameters to the authorization
322
322
  URL. This is how you request a refresh token from Google — Google only returns one
323
323
  when you ask for offline access and force the consent screen:
324
324
 
325
- ```typescript
325
+ ```typescript fragment
326
326
  Social.driver("google").with({ access_type: "offline", prompt: "consent" }).redirect();
327
327
 
328
328
  // On callback, socialUser.refreshToken is now populated.
@@ -337,7 +337,7 @@ If your client already holds an access token — for example a mobile app that r
337
337
  its own native OAuth SDK — skip the code exchange and fetch the profile directly
338
338
  with `userFromToken()`:
339
339
 
340
- ```typescript
340
+ ```typescript fragment
341
341
  const socialUser = await Social.driver("github").userFromToken(accessToken);
342
342
  ```
343
343
 
@@ -350,7 +350,7 @@ When you're building an API backend for an SPA or mobile app, you may receive a
350
350
  raw `code` from the client without a session. Call `.stateless()` to skip CSRF
351
351
  state verification and pass the code directly to `.user()`:
352
352
 
353
- ```typescript
353
+ ```typescript fragment
354
354
  // app/controllers/SocialApiController.ts — POST /auth/callback { provider, code }
355
355
  import { Social } from "@zerotal/auth";
356
356
  import type { HttpContext } from "zerotal";
@@ -386,7 +386,7 @@ async callback(ctx: HttpContext) {
386
386
  `user()` returns a canned profile. Build that profile with `fakeSocialUser()`,
387
387
  overriding only the fields your test cares about:
388
388
 
389
- ```typescript
389
+ ```typescript fragment
390
390
  import { Social, fakeSocialUser } from "@zerotal/auth";
391
391
 
392
392
  test("logs a user in via GitHub", async () => {
@@ -445,7 +445,7 @@ export class TwitterDriver extends OAuth2Driver {
445
445
  Register it via the `Social` facade — typically in an `AppProvider`'s `onBooted`
446
446
  hook, since the `"social"` singleton is bound during `onRegister`:
447
447
 
448
- ```typescript
448
+ ```typescript fragment
449
449
  // app/providers/AppProvider.ts
450
450
  import { ServiceProvider } from "zerotal";
451
451
  import { Social } from "@zerotal/auth";
package/docs/storage.md CHANGED
@@ -65,7 +65,7 @@ A disk outside `storage/public` can still be exposed — with `signed: true`, so
65
65
  every request carries a signature you issued. Serving one openly throws
66
66
  `UnsafePublicMountError` **at boot**, before the server accepts a request:
67
67
 
68
- ```ts
68
+ ```ts fragment
69
69
  // config/storage.ts — refused
70
70
  invoices: { driver: "local", root: "./storage/invoices", serve: { path: "/invoices" } }
71
71
  // ^ no `signed`
@@ -100,7 +100,7 @@ A disk is reachable over the network only if it declares `serve`. The default
100
100
  `public` disk does; the default `local` disk does not, which is what makes it a
101
101
  sensible place for private uploads.
102
102
 
103
- ```ts
103
+ ```ts fragment
104
104
  // config/storage.ts
105
105
  export default StorageConfig({
106
106
  disks: {
@@ -145,6 +145,8 @@ guessing paths; a `404` tells them nothing.
145
145
 
146
146
  ```ts
147
147
  // in a controller
148
+ import { Storage } from "zerotal/storage";
149
+
148
150
  const url = await Storage.disk("invoices").temporaryUrl("2026-q1.pdf", 900);
149
151
  // → /invoices/2026-q1.pdf?expires=1769990400&signature=…
150
152
  ```
@@ -188,7 +190,7 @@ export default StorageConfig({
188
190
 
189
191
  Each disk is either a local or an S3 disk:
190
192
 
191
- ```ts
193
+ ```ts fragment
192
194
  // config/storage.ts — disk shapes
193
195
  local: { driver: "local", root: "./storage/app", url: "/storage" /* optional */ }
194
196
  s3: { driver: "s3", key, secret, region, bucket, endpoint?, url? }
@@ -230,7 +232,7 @@ Storage.disk("s3"); // S3 disk
230
232
 
231
233
  `put()` accepts a string, `Uint8Array`, or `Blob`:
232
234
 
233
- ```ts
235
+ ```ts fragment
234
236
  function put(
235
237
  path: string,
236
238
  content: string | Uint8Array | Blob,
@@ -238,7 +240,7 @@ function put(
238
240
  ): Promise<void>;
239
241
  ```
240
242
 
241
- ```ts
243
+ ```ts fragment
242
244
  // in a controller
243
245
  import { Storage } from "zerotal/storage";
244
246
 
@@ -281,6 +283,8 @@ const bytes = await Storage.disk().getBuffer("uploads/invoice.pdf");
281
283
 
282
284
  ```ts
283
285
  // in a service
286
+ import { Storage } from "zerotal/storage";
287
+
284
288
  const exists = await Storage.disk().exists("avatars/alice.jpg"); // boolean
285
289
  ```
286
290
 
@@ -288,6 +292,8 @@ const exists = await Storage.disk().exists("avatars/alice.jpg"); // boolean
288
292
 
289
293
  ```ts
290
294
  // in a service
295
+ import { Storage } from "zerotal/storage";
296
+
291
297
  await Storage.disk().delete("avatars/old.jpg");
292
298
  ```
293
299
 
@@ -299,6 +305,8 @@ Both operate within the same disk:
299
305
 
300
306
  ```ts
301
307
  // in a service
308
+ import { Storage } from "zerotal/storage";
309
+
302
310
  // Copy
303
311
  await Storage.disk().copy("avatars/alice.jpg", "backups/alice-2026.jpg");
304
312
 
@@ -310,6 +318,8 @@ await Storage.disk().move("tmp/upload.jpg", "avatars/alice.jpg");
310
318
 
311
319
  ```ts
312
320
  // in a service
321
+ import { Storage } from "zerotal/storage";
322
+
313
323
  const size = await Storage.disk().size("avatars/alice.jpg"); // bytes | null
314
324
  const modified = await Storage.disk().lastModified("avatars/alice.jpg"); // ms epoch | null
315
325
  ```
@@ -350,8 +360,10 @@ the panel's own 404, which looks like a broken upload and is not one.
350
360
 
351
361
  Branch instead of catching when a disk may legitimately have no URL:
352
362
 
353
- ```ts
363
+ ```ts fragment
354
364
  // in a view
365
+ import { Storage } from "zerotal/storage";
366
+
355
367
  const src = Storage.isServed("public") ? await Storage.publicUrl(path, { disk: "public" }) : null;
356
368
  ```
357
369
 
@@ -390,7 +402,7 @@ const localSigned = await Storage.disk("invoices").temporaryUrl("2026-q1.pdf", 9
390
402
  Serving the disk with `signed: true` validates the link for you — no route to
391
403
  write. Reach for the verifiers only when you are serving the file yourself:
392
404
 
393
- ```ts
405
+ ```ts fragment
394
406
  Router.get("/files/:path*", async ({ params, request, response }) => {
395
407
  const path = params.path;
396
408
  // Reads ?expires= & ?signature= off the request URL and checks them against `path`.
@@ -498,7 +510,7 @@ Stores files in any S3-compatible service, powered by Bun's native `S3Client` (n
498
510
 
499
511
  **Cloudflare R2:**
500
512
 
501
- ```ts
513
+ ```ts fragment
502
514
  // config/storage.ts — a disk inside disks: { … }
503
515
  r2: {
504
516
  driver: "s3",
@@ -513,7 +525,7 @@ r2: {
513
525
 
514
526
  **MinIO (self-hosted):**
515
527
 
516
- ```ts
528
+ ```ts fragment
517
529
  // config/storage.ts — a disk inside disks: { … }
518
530
  minio: {
519
531
  driver: "s3",
@@ -530,7 +542,7 @@ minio: {
530
542
  Set your suite up once as described in [Testing](/docs/testing). Storage ships
531
543
  two assertions, and they take either a disk name or a driver instance:
532
544
 
533
- ```typescript
545
+ ```typescript fragment
534
546
  // tests/http/avatars.test.ts
535
547
  import { test } from "bun:test";
536
548
  import { assertStoredFile, assertMissingFile } from "@zerotal/testing";
@@ -552,7 +564,7 @@ test("uploading an avatar writes it to the disk", async () => {
552
564
  reason. Configure a temp directory in `tests/helpers.ts` and clear it between
553
565
  runs:
554
566
 
555
- ```typescript
567
+ ```typescript fragment
556
568
  // tests/helpers.ts
557
569
  .useConfig({
558
570
  storage: { default: "local", disks: { local: { driver: "local", root: "./storage/tmp-test" } } },
@@ -563,7 +575,7 @@ runs:
563
575
  that removes a file — a delete endpoint returning `204` proves the route ran, not
564
576
  that the bytes are gone:
565
577
 
566
- ```typescript
578
+ ```typescript fragment
567
579
  // tests/http/avatars.test.ts
568
580
  (await app.actingAs(user).delete("/avatar")).assertNoContent();
569
581
 
@@ -573,7 +585,7 @@ await assertMissingFile("local", `avatars/${user.id}.jpg`);
573
585
  **For a unit test, pass a driver instead of a disk name** — no application, no
574
586
  config, no container:
575
587
 
576
- ```typescript
588
+ ```typescript fragment
577
589
  // tests/services/ReportWriter.test.ts
578
590
  import { LocalDriver } from "zerotal/storage";
579
591
 
package/docs/structure.md CHANGED
@@ -101,7 +101,7 @@ self-register on import. No manual wiring — see
101
101
  **`bootstrap/app.ts`** — the application singleton. Wires together exception
102
102
  handling, global middleware, and opt-in conventions:
103
103
 
104
- ```ts
104
+ ```ts fragment
105
105
  // bootstrap/app.ts
106
106
  import { Application } from "zerotal";
107
107
  import { DevtoolsInjectionMiddleware } from "@zerotal/devtools";
@@ -158,7 +158,7 @@ with `Router.static('/assets', './public/assets')` in `routes/index.ts`.
158
158
  All explicit route registrations. This file runs after file-based routes are
159
159
  scanned, so explicit routes take precedence over file routes for the same path.
160
160
 
161
- ```ts
161
+ ```ts fragment
162
162
  // routes/index.ts
163
163
  import { Router } from "zerotal";
164
164
  import { PostController } from "../app/controllers/PostController.ts";
@@ -49,11 +49,11 @@ course?", so this is it:
49
49
 
50
50
  ## Databases
51
51
 
52
- | Database | Status |
53
- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
54
- | SQLite | Supported. The default; the full test suite runs against it on every merge. |
55
- | PostgreSQL | Supported. A smoke suite runs against a real PostgreSQL 16 on every merge — schema DDL, identity columns, CRUD, type round-trips, row locks and transaction rollback — and the job blocks a merge when it fails. The bulk of the ORM suite still runs on SQLite, so coverage of the Postgres path is narrower than of the default one. |
56
- | MySQL | Experimental. The ORM ships a MySQL dialect and the scaffolder can configure it, but no CI suite runs against a real MySQL server yet treat it as unverified until it joins the tested matrix. |
52
+ | Database | Status |
53
+ | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
54
+ | SQLite | Supported. The default; the full test suite runs against it on every merge. |
55
+ | PostgreSQL | Supported. A smoke suite runs against a real PostgreSQL 16 on every merge — schema DDL and `ALTER`, identity columns, CRUD, type round-trips, unique and NOT NULL enforcement, row locks and transaction rollback — and the job blocks a merge when it fails. The bulk of the ORM suite still runs on SQLite, so the Postgres path is covered more narrowly than the default one. |
56
+ | MySQL | Supported, hardening. The same smoke suite runs against a real MySQL 8 on every merge and blocks on failure. It is newer than the Postgres job and has found one defect already (`string()` was not indexable), so treat MySQL as verified in the paths the suite covers and less proven than PostgreSQL outside them. |
57
57
 
58
58
  Redis-backed drivers (cache, session, queue, broadcasting) build on
59
59
  `Bun.RedisClient` and are tested against the protocol surface it provides.
@@ -70,9 +70,9 @@ dependency order, from CI. Never mix versions across packages.
70
70
  - **One exception, while the 1.x line is young:** a breaking change may land in a
71
71
  minor or a patch when leaving it in place would cost more than the migration
72
72
  does. It is called out in the release notes as **BREAKING**, with the reason and
73
- the migration steps, and it is never silent. Two have shipped so far — the
74
- `ComponentWith` / `BaseModelWith` removal in 1.3.0 and Flow's `socket:` listener
75
- prefix in 1.7.2. This carve-out is a consequence of the project's age, not a
73
+ the migration steps, and it is never silent. Three have shipped so far — the
74
+ `ComponentWith` / `BaseModelWith` removal in 1.3.0, Flow's `socket:` listener
75
+ prefix in 1.7.2, and the removal of Flow's `this.title(…)` in 1.7.3. This carve-out is a consequence of the project's age, not a
76
76
  standing policy; it will be withdrawn, with a version named here, once adoption
77
77
  makes the cost of a break real.
78
78
  - **Provenance:** packages are published with npm provenance, so you can verify
@@ -100,6 +100,34 @@ a contract, not a mood:
100
100
  - **experimental** — no compatibility promise. The API may change or the package
101
101
  may be absorbed into another in any release. Build on it with your eyes open.
102
102
 
103
+ ### A label below stable carries a review date
104
+
105
+ An honest "experimental" is useful once and corrosive indefinitely: a package that
106
+ has worn the label for a year is not being cautious, it is unowned. So each one
107
+ below `stable` names the release by which it is reviewed, and the review has three
108
+ outcomes — promote, keep with a new date and the reason, or withdraw.
109
+
110
+ | Package | Now | Reviewed by |
111
+ | ------------- | -------------- | ----------- |
112
+ | `@zerotal/ai` | `experimental` | **1.9.0** |
113
+
114
+ `@zerotal/arch` held `beta` with the same date, was reviewed ahead of it, and is
115
+ `stable` — the release that carried the promotion is the one its
116
+ [changelog](/docs/changelog) names. Its surface was narrowed first — the writers behind
117
+ `arch:install` are `@internal` now, because they had no caller outside the package
118
+ and freezing them would have promised the shape of `.mcp.json` writing to nobody.
119
+
120
+ `@zerotal/ai` is not in the `zerotal` meta-package and nothing `stable` depends on
121
+ it, so the cost of its label falling due is ours and not yours. Neither is `arch`,
122
+ still: `arch:install` writes configuration and instruction files into a project,
123
+ which is an opinion about someone's toolchain and stays their choice to invite.
124
+
125
+ That table used to be the whole of the commitment, which meant the version could
126
+ sail past it and the only consequence would be this paragraph quietly becoming
127
+ untrue. The review release now lives in each package's `package.json` as
128
+ `maturityReview`, and the package-conventions gate fails once the version reaches
129
+ it — so the deadline is a build failure rather than a promise.
130
+
103
131
  A package is never more mature than what it is built on: a stable package whose
104
132
  foundation can change under it is not stable, whatever its own label says. So
105
133
  `@zerotal/admin` and `@zerotal/monitor` cannot pass `@zerotal/flow`, and the
package/docs/telemetry.md CHANGED
@@ -99,7 +99,7 @@ export default TelemetryConfig({
99
99
  `TelemetryProvider`; if no tracer is registered the callback still runs with a no-op
100
100
  span — no error is thrown, no import guard needed.
101
101
 
102
- ```typescript
102
+ ```typescript fragment
103
103
  // in a service or controller
104
104
  import { withSpan } from "@zerotal/telemetry";
105
105
 
@@ -123,7 +123,7 @@ The span is automatically:
123
123
 
124
124
  Pass `kind` and initial `attributes` as a third options argument:
125
125
 
126
- ```typescript
126
+ ```typescript fragment
127
127
  // in a service
128
128
  await withSpan(
129
129
  "http.outbound",
@@ -142,7 +142,7 @@ The `Span` passed to your callback is fluent — every mutator returns the span.
142
142
 
143
143
  Attribute values must be `string | number | boolean`.
144
144
 
145
- ```typescript
145
+ ```typescript fragment
146
146
  // inside a withSpan() callback
147
147
  span.setAttribute("db.table", "orders"); // single
148
148
  span.setAttributes({ "db.rows": 5, "cache.hit": true }); // batch
@@ -153,7 +153,7 @@ span.setAttributes({ "db.rows": 5, "cache.hit": true }); // batch
153
153
  The default status is `'unset'`; the exporter treats it the same as `'ok'`.
154
154
  `setStatus()` accepts only `'ok'` or `'error'`:
155
155
 
156
- ```typescript
156
+ ```typescript fragment
157
157
  // inside a withSpan() callback
158
158
  span.setStatus("ok");
159
159
  span.setStatus("error", "payment gateway timeout");
@@ -164,7 +164,7 @@ span.setStatus("error", "payment gateway timeout");
164
164
  Events are timestamped log lines attached to a span — useful for recording
165
165
  significant moments inside a long operation.
166
166
 
167
- ```typescript
167
+ ```typescript fragment
168
168
  // inside a withSpan() callback
169
169
  span.addEvent("cache.miss");
170
170
  span.addEvent("db.query", { table: "orders", rows: 5 });
@@ -175,7 +175,7 @@ span.addEvent("db.query", { table: "orders", rows: 5 });
175
175
  `recordException()` adds an `exception` event carrying the error's type, message,
176
176
  and stack:
177
177
 
178
- ```typescript
178
+ ```typescript fragment
179
179
  // inside a withSpan() callback
180
180
  try {
181
181
  await chargeCard(amount);
@@ -196,7 +196,7 @@ Child spans created inside a `withSpan()` callback automatically inherit the par
196
196
  trace ID and attach as children — no manual wiring. Context flows through the async
197
197
  call stack via `AsyncLocalStorage`.
198
198
 
199
- ```typescript
199
+ ```typescript fragment
200
200
  // in a request handler
201
201
  await withSpan("handle-request", async () => {
202
202
  // These nested spans automatically become children:
@@ -242,7 +242,7 @@ tracing shows the same signal the panel does, with no extra instrumentation. Cor
242
242
  never depends on telemetry; telemetry subscribes to core. You can install the bridge
243
243
  manually onto any tracer:
244
244
 
245
- ```typescript
245
+ ```typescript fragment
246
246
  import { installEventBridge } from "@zerotal/telemetry";
247
247
 
248
248
  const dispose = installEventBridge(tracer); // returns an unsubscribe fn
@@ -257,7 +257,7 @@ to trace work that _isn't_ already a framework event.
257
257
  request. Register it near the top of the middleware stack, after
258
258
  `LoggerMiddleware`:
259
259
 
260
- ```typescript
260
+ ```typescript fragment
261
261
  // bootstrap/middleware.ts (or where the stack is declared)
262
262
  import { TelemetryMiddleware } from "@zerotal/telemetry";
263
263
 
@@ -282,7 +282,7 @@ A status of `500` or higher sets the span status to `error`. The span name defau
282
282
  to `"METHOD /pathname"` (e.g. `"GET /api/users"`). Override it with a custom
283
283
  function:
284
284
 
285
- ```typescript
285
+ ```typescript fragment
286
286
  // where you register the middleware
287
287
  TelemetryMiddleware.with({
288
288
  spanName: (ctx) => `${ctx.request.method} ${ctx.params.route ?? ctx.url.pathname}`,
@@ -356,7 +356,7 @@ export default TelemetryConfig({
356
356
  For advanced scenarios — testing, multi-tracer setups, or libraries — create and
357
357
  use a `Tracer` instance directly instead of the global one.
358
358
 
359
- ```typescript
359
+ ```typescript fragment
360
360
  // in a library or test
361
361
  import { Tracer, ConsoleExporter } from "@zerotal/telemetry";
362
362
 
@@ -377,7 +377,7 @@ await tracer.withSpan("my-op", async (span) => {
377
377
  Use `startSpan()` for fire-and-forget work, or when the span lifetime doesn't map
378
378
  neatly to a single async function. The caller owns the lifecycle:
379
379
 
380
- ```typescript
380
+ ```typescript fragment
381
381
  // in a stream/queue worker
382
382
  const span = tracer.startSpan("stream-processor", { kind: "consumer" });
383
383
  span.setAttribute("queue", "orders");