@zerotal/arch 1.7.4 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +86 -2
- package/README.md +4 -2
- package/api-surface.md +2 -0
- 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 +268 -1
- package/docs/client/index.md +13 -13
- package/docs/commands.md +36 -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 +99 -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 +21 -21
- package/docs/flow/layouts.md +99 -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 +29 -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/config.ts +8 -0
- package/src/index.ts +25 -1
- package/src/install/ArchInstallCommand.ts +46 -3
- package/src/install/guidelines.ts +84 -0
- package/src/install/shape.ts +177 -0
- package/src/install/skills.ts +271 -0
- package/src/provider/ArchProvider.ts +148 -0
- package/src/tools/searchDocs.ts +7 -0
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
|
@@ -71,6 +71,30 @@ Each entry is a `DeployTarget`:
|
|
|
71
71
|
Omit the file entirely and you get `DEFAULT_DEPLOY_TARGETS`: `production` and
|
|
72
72
|
`staging`, both with the default steps.
|
|
73
73
|
|
|
74
|
+
The defaults build and migrate; they do not check anything you wrote. A preflight command
|
|
75
|
+
of your own — `release:check`, a smoke test — has to be named in `steps` to run, and
|
|
76
|
+
nothing prompts you to add it, so a command written precisely to guard a release can sit
|
|
77
|
+
there never running. Name every step you want, in the order you want them:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
// config/deploy.ts
|
|
81
|
+
export default {
|
|
82
|
+
targets: {
|
|
83
|
+
production: {
|
|
84
|
+
url: "https://example.com",
|
|
85
|
+
steps: ["release:check", "assets:build", "inertia:build", "migrate"],
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Put a check first. A step that fails stops the release, and a check that runs after the
|
|
92
|
+
migration has already run has missed its moment.
|
|
93
|
+
|
|
94
|
+
Two things worth adding while you are there: `assets:build` and `inertia:build` accept
|
|
95
|
+
`--clean`, which removes anything in the output directory the build did not write — see
|
|
96
|
+
[Build assets](#build-assets).
|
|
97
|
+
|
|
74
98
|
> **Note** — `deploy:<env>` runs **where the app runs**, with that environment's
|
|
75
99
|
> variables. It does not reach another machine over SSH. Run it on the box, or in
|
|
76
100
|
> the container build, as the step before the restart.
|
|
@@ -176,6 +200,56 @@ release that built its assets ahead of time and locked the tree down serves what
|
|
|
176
200
|
shipped, and logs one line saying so. That is what lets the service run under a properly
|
|
177
201
|
hardened unit; see [Hardening the service](#hardening-the-service).
|
|
178
202
|
|
|
203
|
+
### Replace the asset directory on release, do not merge into it
|
|
204
|
+
|
|
205
|
+
Code splitting names every chunk after its content, so each build emits a new set and
|
|
206
|
+
abandons the last. Both build commands clean those up as they go, and they do it without
|
|
207
|
+
needing anything to have survived from the previous build — so the directory a build
|
|
208
|
+
writes holds that build's output and nothing else, on a developer's machine and a fresh CI
|
|
209
|
+
checkout alike.
|
|
210
|
+
|
|
211
|
+
What the build cannot clean is a directory it never sees. A release that is **unpacked over
|
|
212
|
+
the top** of the running one — `tar -xzf` into the app directory, `rsync` without
|
|
213
|
+
`--delete` — merges: every file in the archive is written, and every file that is not in
|
|
214
|
+
the archive is left exactly where it was. Nothing on that server ever ran a build, so
|
|
215
|
+
nothing ever removes last release's chunks, and they collect one release at a time.
|
|
216
|
+
|
|
217
|
+
That is not only clutter. They stay publicly fetchable at their content-hashed URLs, so a
|
|
218
|
+
page whose copy you withdrew is still readable by anyone holding the link — pricing you
|
|
219
|
+
took down, a policy you replaced, a feature you pulled.
|
|
220
|
+
|
|
221
|
+
Clear the directory as part of the release, before the new files land:
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
# on the server, before extracting
|
|
225
|
+
rm -rf "$APP_DIR/public/assets"
|
|
226
|
+
tar -xzf release.tgz -C "$APP_DIR"
|
|
227
|
+
|
|
228
|
+
# or let rsync do it
|
|
229
|
+
rsync -a --delete public/assets/ "$HOST:$APP_DIR/public/assets/"
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Ordering matters if the old release is still serving traffic: clearing the directory takes
|
|
233
|
+
its bundles away, so do it as close to the swap as you can, or stage the release in a new
|
|
234
|
+
directory and move it into place.
|
|
235
|
+
|
|
236
|
+
### `--clean` for a directory the build does not own outright
|
|
237
|
+
|
|
238
|
+
The cleanup above recognises the filenames `Bun.build()` produces. An app that sets its own
|
|
239
|
+
`naming`, or that writes a second bundle into the same directory by other means, can leave
|
|
240
|
+
output the build does not recognise as its own. `--clean` needs no recognition — whatever
|
|
241
|
+
this build did not write, goes:
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
# in your project root
|
|
245
|
+
bun zt assets:build --clean
|
|
246
|
+
bun zt inertia:build --production --clean
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
It refuses `public/` itself and the project root, where deleting what was not rebuilt would
|
|
250
|
+
take the app's images and favicon with it. Point the build at a directory of its own —
|
|
251
|
+
`public/assets` is the Inertia default.
|
|
252
|
+
|
|
179
253
|
Bump your asset version (or hash the bundle) so clients reload onto the new build — see
|
|
180
254
|
[Inertia › Asset versioning](/docs/inertia/middleware#asset-versioning).
|
|
181
255
|
|
|
@@ -228,7 +302,7 @@ which behind a proxy is the loopback address it bound to (`http://127.0.0.1:3002
|
|
|
228
302
|
the public URL the browser sends. So the public origin has to come from config, and it
|
|
229
303
|
does: `AppConfig()` fills `app.allowedOrigins` from `url`.
|
|
230
304
|
|
|
231
|
-
```ts
|
|
305
|
+
```ts fragment
|
|
232
306
|
// config/app.ts
|
|
233
307
|
export default AppConfig({
|
|
234
308
|
name: "My App",
|
|
@@ -240,7 +314,7 @@ Name additional origins only when a genuinely different host drives the app —
|
|
|
240
314
|
`app.example.com` calling `api.example.com`. What you pass is added to the URL's origin
|
|
241
315
|
rather than replacing it:
|
|
242
316
|
|
|
243
|
-
```ts
|
|
317
|
+
```ts fragment
|
|
244
318
|
// config/app.ts
|
|
245
319
|
export default AppConfig({
|
|
246
320
|
url: env("APP_URL"),
|
|
@@ -253,6 +327,29 @@ Origins are compared exactly: no wildcards and no suffix matching, because
|
|
|
253
327
|
|
|
254
328
|
> **Warning** — an app with the wrong origin configured renders every page correctly and refuses every action. There is no 500, nothing in the logs, and a status-code health check passes. The only symptom is that buttons do nothing.
|
|
255
329
|
|
|
330
|
+
### Rate limiting counts the proxy, not the visitor
|
|
331
|
+
|
|
332
|
+
`ThrottleMiddleware` identifies a client by the address the request arrived from. Behind a
|
|
333
|
+
proxy that address is the proxy — `127.0.0.1` for every visitor on the site — so one bucket
|
|
334
|
+
is shared by everybody, and a single busy client can lock the whole site out.
|
|
335
|
+
|
|
336
|
+
Tell it how many proxies sit in front of the app:
|
|
337
|
+
|
|
338
|
+
```ts fragment
|
|
339
|
+
// One proxy (Caddy, nginx, a load balancer) between the internet and the app.
|
|
340
|
+
ThrottleMiddleware.with({ maxAttempts: 60, trustedProxies: 1 });
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
The count is how many entries to skip from the right of `X-Forwarded-For`. It is opt-in and
|
|
344
|
+
defaults to zero because the header is written by the client until something trusted
|
|
345
|
+
overwrites it: trusting it by default would let anyone forge an address and walk around
|
|
346
|
+
every limit you set. Zero is the safe default and the wrong answer once you deploy behind
|
|
347
|
+
something — which is easy to do and never revisit, because nothing about it fails loudly.
|
|
348
|
+
The symptom is a legitimate visitor getting a 429 they did not earn.
|
|
349
|
+
|
|
350
|
+
Count the proxies you actually run. Setting `trustedProxies: 3` with one proxy in front
|
|
351
|
+
reads an entry the client supplied.
|
|
352
|
+
|
|
256
353
|
### Never gate the transport path
|
|
257
354
|
|
|
258
355
|
**Browsers do not attach basic-auth credentials to a WebSocket handshake.** An HTTP auth
|
package/docs/devtools.md
CHANGED
|
@@ -57,7 +57,7 @@ lifecycle order, all of them additionally short-circuited in production):
|
|
|
57
57
|
> **Note** — Nothing happens at import time. The trace store opens its database
|
|
58
58
|
> on first use rather than in a constructor, so importing this package in an app
|
|
59
59
|
> that runs in production writes nothing and starts no timer. N+1 detection
|
|
60
|
-
> itself is owned by the [ORM](/docs/orm
|
|
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.
|