@zerotal/arch 1.7.4 → 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.
- package/CHANGELOG.md +34 -2
- package/README.md +4 -2
- package/docs/about.md +16 -16
- package/docs/admin/actions.md +9 -9
- package/docs/admin/auth.md +2 -2
- package/docs/admin/dashboard.md +5 -5
- package/docs/admin/extending-ui.md +12 -12
- package/docs/admin/extending.md +16 -7
- package/docs/admin/forms.md +4 -4
- package/docs/admin/index.md +2 -2
- package/docs/admin/operations.md +9 -9
- package/docs/admin/resources.md +3 -3
- package/docs/admin/structure.md +6 -6
- package/docs/admin/tables.md +8 -8
- package/docs/admin/testing.md +4 -4
- package/docs/ai.md +118 -11
- package/docs/application.md +12 -12
- package/docs/arch.md +25 -3
- package/docs/assets.md +11 -11
- package/docs/audit.md +13 -13
- package/docs/authentication.md +39 -39
- package/docs/authorization.md +13 -13
- package/docs/broadcasting/channels.md +2 -2
- package/docs/broadcasting/client.md +2 -2
- package/docs/broadcasting/events.md +9 -10
- package/docs/broadcasting/index.md +1 -1
- package/docs/broadcasting/testing.md +5 -5
- package/docs/cache.md +15 -13
- package/docs/carbon.md +35 -35
- package/docs/changelog.md +105 -1
- package/docs/client/index.md +13 -13
- package/docs/commands.md +4 -4
- package/docs/components.md +116 -115
- package/docs/config-system.md +4 -4
- package/docs/container.md +27 -27
- package/docs/context.md +27 -27
- package/docs/contributing.md +28 -0
- package/docs/controllers.md +10 -10
- package/docs/conventions.md +23 -15
- package/docs/cookies.md +3 -3
- package/docs/csrf.md +6 -6
- package/docs/database.md +22 -17
- package/docs/deployment.md +2 -2
- package/docs/devtools.md +16 -16
- package/docs/email-verification.md +8 -8
- package/docs/encryption.md +7 -7
- package/docs/errors.md +6 -6
- package/docs/events.md +12 -11
- package/docs/flow/components.md +32 -32
- package/docs/flow/decorators.md +18 -18
- package/docs/flow/events.md +16 -16
- package/docs/flow/forms.md +17 -17
- package/docs/flow/icons.md +5 -5
- package/docs/flow/index.md +20 -20
- package/docs/flow/layouts.md +35 -35
- package/docs/flow/lifecycle.md +16 -16
- package/docs/flow/models.md +9 -9
- package/docs/flow/pagination.md +8 -8
- package/docs/flow/performance.md +7 -7
- package/docs/flow/references.md +3 -3
- package/docs/flow/routing.md +26 -26
- package/docs/flow/testing.md +20 -20
- package/docs/getting-started.md +18 -13
- package/docs/health.md +17 -8
- package/docs/helpers.md +17 -17
- package/docs/i18n.md +10 -10
- package/docs/inertia/devtools.md +4 -4
- package/docs/inertia/index.md +6 -6
- package/docs/inertia/props.md +21 -21
- package/docs/inertia/rendering.md +9 -9
- package/docs/inertia/ssr.md +2 -2
- package/docs/lifecycle.md +2 -2
- package/docs/lock.md +11 -11
- package/docs/logger.md +12 -12
- package/docs/media.md +22 -22
- package/docs/middleware.md +17 -17
- package/docs/migrations.md +13 -13
- package/docs/monitor.md +6 -6
- package/docs/notifications.md +24 -24
- package/docs/orm/casts.md +14 -14
- package/docs/orm/factories.md +8 -8
- package/docs/orm/index.md +22 -20
- package/docs/orm/lifecycle.md +13 -13
- package/docs/orm/queries.md +28 -28
- package/docs/orm/relationships.md +16 -16
- package/docs/orm/serialization.md +11 -11
- package/docs/package-development.md +10 -10
- package/docs/pagination.md +14 -14
- package/docs/password-reset.md +6 -6
- package/docs/providers.md +11 -11
- package/docs/query-builder.md +18 -18
- package/docs/queue.md +14 -12
- package/docs/rate-limiting.md +6 -6
- package/docs/responses.md +14 -14
- package/docs/roles-and-2fa.md +9 -9
- package/docs/routing.md +44 -44
- package/docs/scaffolding.md +1 -1
- package/docs/scheduler.md +10 -10
- package/docs/seeding.md +6 -6
- package/docs/session.md +16 -14
- package/docs/social.md +14 -14
- package/docs/storage.md +25 -13
- package/docs/structure.md +2 -2
- package/docs/support-policy.md +23 -10
- package/docs/telemetry.md +12 -12
- package/docs/tenancy.md +34 -40
- package/docs/testing/browser.md +6 -6
- package/docs/testing/console.md +3 -3
- package/docs/testing/database.md +10 -10
- package/docs/testing/flow-browser.md +6 -6
- package/docs/testing/http.md +12 -12
- package/docs/testing/index.md +3 -3
- package/docs/testing/mocking.md +8 -8
- package/docs/upgrade.md +1 -1
- package/docs/validator.md +19 -19
- package/docs/view.md +14 -14
- package/package.json +4 -4
- package/src/index.ts +25 -1
- package/src/tools/searchDocs.ts +7 -0
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
|
-
|
|
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.
|
|
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";
|
package/docs/support-policy.md
CHANGED
|
@@ -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.
|
|
74
|
-
`ComponentWith` / `BaseModelWith` removal in 1.3.0
|
|
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
|
|
@@ -107,13 +107,26 @@ has worn the label for a year is not being cautious, it is unowned. So each one
|
|
|
107
107
|
below `stable` names the release by which it is reviewed, and the review has three
|
|
108
108
|
outcomes — promote, keep with a new date and the reason, or withdraw.
|
|
109
109
|
|
|
110
|
-
| Package
|
|
111
|
-
|
|
|
112
|
-
| `@zerotal/ai`
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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.
|
|
117
130
|
|
|
118
131
|
A package is never more mature than what it is built on: a stable package whose
|
|
119
132
|
foundation can change under it is not stable, whatever its own label says. So
|
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");
|