@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.
- package/CHANGELOG.md +34 -2
- package/README.md +4 -2
- package/api-surface.md +4 -4
- 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 +165 -1
- package/docs/client/index.md +277 -70
- 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 +46 -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 +199 -0
- 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 +12 -7
- package/docs/inertia/props.md +22 -22
- package/docs/inertia/rendering.md +86 -7
- 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 +36 -8
- 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/client/auth.md +0 -113
- package/docs/client/errors.md +0 -139
- package/docs/client/files.md +0 -118
- package/docs/client/references.md +0 -58
- package/docs/client/requests.md +0 -131
- package/docs/client/resilience.md +0 -141
- package/docs/client/testing.md +0 -146
package/docs/conventions.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
444
|
+
import { FrameworkEvents } from "zerotal";
|
|
445
|
+
import { QueryExecuted } from "@zerotal/orm";
|
|
441
446
|
|
|
442
447
|
// Log slow queries
|
|
443
448
|
FrameworkEvents.on(QueryExecuted, (e) => {
|
package/docs/deployment.md
CHANGED
|
@@ -228,7 +228,7 @@ which behind a proxy is the loopback address it bound to (`http://127.0.0.1:3002
|
|
|
228
228
|
the public URL the browser sends. So the public origin has to come from config, and it
|
|
229
229
|
does: `AppConfig()` fills `app.allowedOrigins` from `url`.
|
|
230
230
|
|
|
231
|
-
```ts
|
|
231
|
+
```ts fragment
|
|
232
232
|
// config/app.ts
|
|
233
233
|
export default AppConfig({
|
|
234
234
|
name: "My App",
|
|
@@ -240,7 +240,7 @@ Name additional origins only when a genuinely different host drives the app —
|
|
|
240
240
|
`app.example.com` calling `api.example.com`. What you pass is added to the URL's origin
|
|
241
241
|
rather than replacing it:
|
|
242
242
|
|
|
243
|
-
```ts
|
|
243
|
+
```ts fragment
|
|
244
244
|
// config/app.ts
|
|
245
245
|
export default AppConfig({
|
|
246
246
|
url: env("APP_URL"),
|
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
|
|
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
|
|
1013
|
+
- [Testing](/docs/testing) — assert on requests without the floating panel.
|
|
@@ -18,7 +18,7 @@ in `@zerotal/core` signs the link, and `ValidateSignatureMiddleware` in
|
|
|
18
18
|
`@zerotal/auth` checks it. There is no extra package and no extra table:
|
|
19
19
|
|
|
20
20
|
```typescript
|
|
21
|
-
import { Url } from "zerotal";
|
|
21
|
+
import { Url } from "zerotal/http";
|
|
22
22
|
import { ValidateSignatureMiddleware } from "@zerotal/auth";
|
|
23
23
|
```
|
|
24
24
|
|
|
@@ -51,7 +51,7 @@ register ──▶ Url.sign(/auth/verify?id&email, ttl) ──▶ email link
|
|
|
51
51
|
|
|
52
52
|
Add a nullable `email_verified_at` column to your users table:
|
|
53
53
|
|
|
54
|
-
```typescript
|
|
54
|
+
```typescript fragment
|
|
55
55
|
// database/migrations/002_add_email_verified_at.ts
|
|
56
56
|
export default class AddEmailVerifiedAt extends Migration {
|
|
57
57
|
async up(schema: Schema) {
|
|
@@ -140,7 +140,7 @@ The verify route is already guarded by `ValidateSignatureMiddleware`, so the
|
|
|
140
140
|
controller can trust that the URL is intact — it only checks that the `id`/`email`
|
|
141
141
|
pair still resolves to a real user:
|
|
142
142
|
|
|
143
|
-
```typescript
|
|
143
|
+
```typescript fragment
|
|
144
144
|
// app/controllers/VerificationController.ts
|
|
145
145
|
import { Auth } from "@zerotal/auth";
|
|
146
146
|
import { Notify } from "@zerotal/notifications";
|
|
@@ -199,7 +199,7 @@ export class VerificationController {
|
|
|
199
199
|
|
|
200
200
|
## Routes
|
|
201
201
|
|
|
202
|
-
```typescript
|
|
202
|
+
```typescript fragment
|
|
203
203
|
// routes/web.ts
|
|
204
204
|
import { ValidateSignatureMiddleware } from "@zerotal/auth";
|
|
205
205
|
import { RequireAuthMiddleware } from "#app/middleware/RequireAuth.ts";
|
|
@@ -220,7 +220,7 @@ Router.post("/auth/verify/resend", VerificationController, "resend", [RequireAut
|
|
|
220
220
|
Call `Notify.queue()` at the end of your registration handler so the HTTP response
|
|
221
221
|
returns immediately while delivery happens in the background:
|
|
222
222
|
|
|
223
|
-
```typescript
|
|
223
|
+
```typescript fragment
|
|
224
224
|
// app/controllers/AuthController.ts
|
|
225
225
|
import { Notify } from "@zerotal/notifications";
|
|
226
226
|
import { VerifyEmailNotification } from "#app/notifications/VerifyEmailNotification.ts";
|
|
@@ -242,7 +242,7 @@ Write a `VerifiedMiddleware` once and apply it to routes that must only be
|
|
|
242
242
|
accessible to verified users. It extends `BaseMiddleware` (from `zerotal`)
|
|
243
243
|
and reads the current user via `Auth.userOrNull()`:
|
|
244
244
|
|
|
245
|
-
```typescript
|
|
245
|
+
```typescript fragment
|
|
246
246
|
// app/middleware/Verified.ts
|
|
247
247
|
import { BaseMiddleware } from "zerotal";
|
|
248
248
|
import type { HttpContext, NextFn } from "zerotal";
|
|
@@ -273,7 +273,7 @@ export class VerifiedMiddleware extends BaseMiddleware {
|
|
|
273
273
|
Apply it alongside `RequireAuthMiddleware` so unverified users are bounced to the
|
|
274
274
|
notice page:
|
|
275
275
|
|
|
276
|
-
```typescript
|
|
276
|
+
```typescript fragment
|
|
277
277
|
// routes/web.ts
|
|
278
278
|
Router.group({ middleware: [RequireAuthMiddleware, VerifiedMiddleware] }, () => {
|
|
279
279
|
Router.get("/dashboard", DashboardController, "index");
|
|
@@ -284,7 +284,7 @@ Router.group({ middleware: [RequireAuthMiddleware, VerifiedMiddleware] }, () =>
|
|
|
284
284
|
|
|
285
285
|
## Testing
|
|
286
286
|
|
|
287
|
-
```typescript
|
|
287
|
+
```typescript fragment
|
|
288
288
|
// tests/email-verification.test.ts
|
|
289
289
|
it("unverified user is redirected to notice page", async () => {
|
|
290
290
|
const user = await UserFactory.create({ emailVerifiedAt: null });
|
package/docs/encryption.md
CHANGED
|
@@ -81,7 +81,7 @@ Crypt.decrypt<{ userId: number }>(blob).userId; // 7
|
|
|
81
81
|
algorithm is auto-detected on verify, so changing the default never breaks
|
|
82
82
|
existing hashes.
|
|
83
83
|
|
|
84
|
-
```ts
|
|
84
|
+
```ts fragment
|
|
85
85
|
// in a controller or service
|
|
86
86
|
import { Hash } from "zerotal/security";
|
|
87
87
|
|
|
@@ -147,7 +147,7 @@ thread a signing key through your code.
|
|
|
147
147
|
|
|
148
148
|
### Generating a signed URL
|
|
149
149
|
|
|
150
|
-
```ts
|
|
150
|
+
```ts fragment
|
|
151
151
|
function sign(
|
|
152
152
|
base: string,
|
|
153
153
|
params?: Record<string, string>,
|
|
@@ -173,7 +173,7 @@ per-feature invite secret): `Url.sign(base, params, minutes, inviteSecret)`.
|
|
|
173
173
|
|
|
174
174
|
### Verifying a signed URL
|
|
175
175
|
|
|
176
|
-
```ts
|
|
176
|
+
```ts fragment
|
|
177
177
|
// in a controller
|
|
178
178
|
import { Url } from "zerotal/http";
|
|
179
179
|
|
|
@@ -209,7 +209,7 @@ Apply `ValidateSignatureMiddleware` to any route that receives signed URLs — i
|
|
|
209
209
|
rejects invalid or expired links with a 403 JSON response, so your controller
|
|
210
210
|
only runs when the signature is good:
|
|
211
211
|
|
|
212
|
-
```ts
|
|
212
|
+
```ts fragment
|
|
213
213
|
// routes/web.ts
|
|
214
214
|
import { ValidateSignatureMiddleware } from "@zerotal/auth";
|
|
215
215
|
|
|
@@ -277,7 +277,7 @@ that a round-trip test alone would never notice.
|
|
|
277
277
|
**Tampering must throw**, and proving it is what tells you the payload is
|
|
278
278
|
authenticated rather than merely scrambled:
|
|
279
279
|
|
|
280
|
-
```typescript
|
|
280
|
+
```typescript fragment
|
|
281
281
|
// tests/security/Crypt.test.ts
|
|
282
282
|
import { DecryptionError } from "zerotal/security";
|
|
283
283
|
|
|
@@ -292,7 +292,7 @@ test("a modified payload is rejected", () => {
|
|
|
292
292
|
**`encrypt()` round-trips any JSON value**, so a test covering an object confirms
|
|
293
293
|
the serialisation as well as the cipher:
|
|
294
294
|
|
|
295
|
-
```typescript
|
|
295
|
+
```typescript fragment
|
|
296
296
|
// tests/security/Crypt.test.ts
|
|
297
297
|
const payload = Crypt.encrypt({ userId: 7, scopes: ["read"] });
|
|
298
298
|
|
|
@@ -303,7 +303,7 @@ Hashing has its own trap: every `Hash` method except `needsRehash` is async, and
|
|
|
303
303
|
forgotten `await` yields a `Promise`, which is always truthy — the one mistake in
|
|
304
304
|
this API that silently passes.
|
|
305
305
|
|
|
306
|
-
```typescript
|
|
306
|
+
```typescript fragment
|
|
307
307
|
// tests/security/Hash.test.ts
|
|
308
308
|
import { Hash } from "zerotal/security";
|
|
309
309
|
|