@zerotal/arch 1.7.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 +31 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/api-surface.md +283 -0
- package/docs/about.md +676 -0
- package/docs/admin/actions.md +183 -0
- package/docs/admin/auth.md +89 -0
- package/docs/admin/dashboard.md +155 -0
- package/docs/admin/extending-ui.md +229 -0
- package/docs/admin/extending.md +205 -0
- package/docs/admin/forms.md +155 -0
- package/docs/admin/index.md +144 -0
- package/docs/admin/operations.md +224 -0
- package/docs/admin/references.md +174 -0
- package/docs/admin/resources.md +132 -0
- package/docs/admin/structure.md +158 -0
- package/docs/admin/tables.md +185 -0
- package/docs/admin/testing.md +138 -0
- package/docs/ai.md +373 -0
- package/docs/application.md +435 -0
- package/docs/arch.md +263 -0
- package/docs/assets.md +755 -0
- package/docs/audit.md +367 -0
- package/docs/authentication.md +1000 -0
- package/docs/authorization.md +529 -0
- package/docs/broadcasting/channels.md +170 -0
- package/docs/broadcasting/client.md +108 -0
- package/docs/broadcasting/events.md +190 -0
- package/docs/broadcasting/index.md +163 -0
- package/docs/broadcasting/references.md +84 -0
- package/docs/broadcasting/testing.md +132 -0
- package/docs/cache.md +387 -0
- package/docs/carbon.md +830 -0
- package/docs/changelog.md +465 -0
- package/docs/client/auth.md +113 -0
- package/docs/client/errors.md +139 -0
- package/docs/client/files.md +118 -0
- package/docs/client/index.md +128 -0
- package/docs/client/references.md +58 -0
- package/docs/client/requests.md +131 -0
- package/docs/client/resilience.md +141 -0
- package/docs/client/testing.md +146 -0
- package/docs/commands.md +450 -0
- package/docs/components.md +2427 -0
- package/docs/config-system.md +258 -0
- package/docs/container.md +596 -0
- package/docs/context.md +579 -0
- package/docs/contributing.md +164 -0
- package/docs/controllers.md +354 -0
- package/docs/conventions.md +466 -0
- package/docs/cookies.md +101 -0
- package/docs/csrf.md +206 -0
- package/docs/database.md +476 -0
- package/docs/deployment.md +434 -0
- package/docs/devtools.md +1013 -0
- package/docs/email-verification.md +320 -0
- package/docs/encryption.md +380 -0
- package/docs/errors.md +317 -0
- package/docs/events.md +420 -0
- package/docs/flow/components.md +846 -0
- package/docs/flow/decorators.md +458 -0
- package/docs/flow/events.md +420 -0
- package/docs/flow/forms.md +551 -0
- package/docs/flow/index.md +568 -0
- package/docs/flow/layouts.md +793 -0
- package/docs/flow/lifecycle.md +336 -0
- package/docs/flow/pagination.md +402 -0
- package/docs/flow/performance.md +262 -0
- package/docs/flow/references.md +317 -0
- package/docs/flow/routing.md +550 -0
- package/docs/flow/testing.md +447 -0
- package/docs/getting-started.md +286 -0
- package/docs/health.md +269 -0
- package/docs/helpers.md +423 -0
- package/docs/i18n.md +414 -0
- package/docs/index.md +65 -0
- package/docs/inertia/build.md +95 -0
- package/docs/inertia/devtools.md +197 -0
- package/docs/inertia/index.md +267 -0
- package/docs/inertia/middleware.md +81 -0
- package/docs/inertia/props.md +507 -0
- package/docs/inertia/references.md +144 -0
- package/docs/inertia/rendering.md +189 -0
- package/docs/inertia/ssr.md +102 -0
- package/docs/inspirations.md +257 -0
- package/docs/lifecycle.md +331 -0
- package/docs/lock.md +454 -0
- package/docs/logger.md +517 -0
- package/docs/media.md +582 -0
- package/docs/middleware.md +509 -0
- package/docs/migrations.md +529 -0
- package/docs/monitor.md +377 -0
- package/docs/notifications.md +848 -0
- package/docs/orm/casts.md +365 -0
- package/docs/orm/factories.md +231 -0
- package/docs/orm/index.md +663 -0
- package/docs/orm/lifecycle.md +421 -0
- package/docs/orm/queries.md +692 -0
- package/docs/orm/relationships.md +440 -0
- package/docs/orm/serialization.md +251 -0
- package/docs/package-development.md +536 -0
- package/docs/pagination.md +340 -0
- package/docs/password-reset.md +335 -0
- package/docs/providers.md +447 -0
- package/docs/query-builder.md +465 -0
- package/docs/queue.md +549 -0
- package/docs/rate-limiting.md +253 -0
- package/docs/responses.md +336 -0
- package/docs/roles-and-2fa.md +418 -0
- package/docs/routing.md +1008 -0
- package/docs/scaffolding.md +335 -0
- package/docs/scheduler.md +533 -0
- package/docs/seeding.md +229 -0
- package/docs/session.md +451 -0
- package/docs/social.md +524 -0
- package/docs/storage.md +622 -0
- package/docs/structure.md +230 -0
- package/docs/support-policy.md +114 -0
- package/docs/telemetry.md +505 -0
- package/docs/tenancy.md +608 -0
- package/docs/testing/browser.md +153 -0
- package/docs/testing/console.md +130 -0
- package/docs/testing/database.md +238 -0
- package/docs/testing/flow-browser.md +216 -0
- package/docs/testing/http.md +441 -0
- package/docs/testing/index.md +240 -0
- package/docs/testing/mocking.md +410 -0
- package/docs/upgrade.md +192 -0
- package/docs/validator.md +549 -0
- package/docs/view.md +453 -0
- package/package.json +60 -0
- package/src/bin/mcp.ts +73 -0
- package/src/config.ts +63 -0
- package/src/errors.ts +27 -0
- package/src/index.ts +56 -0
- package/src/install/ArchInstallCommand.ts +189 -0
- package/src/install/detect.ts +98 -0
- package/src/install/guidelines.ts +290 -0
- package/src/install/markers.ts +77 -0
- package/src/install/mcpConfig.ts +81 -0
- package/src/mcp/index.ts +27 -0
- package/src/mcp/jsonrpc.ts +133 -0
- package/src/mcp/server.ts +307 -0
- package/src/mcp/stdio.ts +111 -0
- package/src/mcp/types.ts +146 -0
- package/src/probe/ArchProbeCommand.ts +48 -0
- package/src/probe/sentinel.ts +13 -0
- package/src/probe/topics.ts +291 -0
- package/src/provider/ArchProvider.ts +101 -0
- package/src/tools/_probe.ts +164 -0
- package/src/tools/apiSurface.ts +241 -0
- package/src/tools/appInfo.ts +88 -0
- package/src/tools/baselines.ts +210 -0
- package/src/tools/context.ts +37 -0
- package/src/tools/doctor.ts +93 -0
- package/src/tools/index.ts +50 -0
- package/src/tools/logs.ts +265 -0
- package/src/tools/routes.ts +133 -0
- package/src/tools/schema.ts +119 -0
- package/src/tools/searchDocs.ts +345 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Conventions
|
|
3
|
+
description: How Zerotal auto-discovers and wires your app/* classes at boot, with no manual register() calls.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Conventions
|
|
7
|
+
|
|
8
|
+
Zerotal auto-discovers and wires your app classes by **convention** at boot — drop a file in the
|
|
9
|
+
right `app/*` directory and it just works, no manual `register()` calls. This covers providers,
|
|
10
|
+
middleware, services, models, observers, policies, event listeners, events, jobs, schedules, and
|
|
11
|
+
validators, plus optional auto-migration.
|
|
12
|
+
|
|
13
|
+
## Mental model
|
|
14
|
+
|
|
15
|
+
Each kind of class lives in a well-known directory; a **concern descriptor** knows how to scan
|
|
16
|
+
that directory and register what it finds. Core owns a few concerns (events, services, listeners,
|
|
17
|
+
jobs, validators); packages contribute the rest (`models`/`observers` from ORM, `policies` from
|
|
18
|
+
auth, `schedules` from scheduler) via `app.registerConcern(...)`, so core depends on neither ORM
|
|
19
|
+
nor auth.
|
|
20
|
+
|
|
21
|
+
Discovery happens at three points in the boot sequence, each ordered so later steps can rely on
|
|
22
|
+
earlier ones:
|
|
23
|
+
|
|
24
|
+
```text
|
|
25
|
+
# boot timeline (left → right)
|
|
26
|
+
|
|
27
|
+
app/providers/* → register → boot phases → app/middleware/* → convention phase
|
|
28
|
+
(before register, (your explicit + the (before routes (everything else,
|
|
29
|
+
full lifecycle) discovered providers) load) concerns by order)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- **Providers** are discovered _before_ the register phase, so they run their full lifecycle.
|
|
33
|
+
- **Middleware** is discovered _before_ routes load, so routes can reference it by name.
|
|
34
|
+
- Everything else runs in the **convention phase**, after all providers have booted, with each
|
|
35
|
+
concern sorted by its `order` (lower runs first).
|
|
36
|
+
|
|
37
|
+
> **Note** — In development and tests Zerotal scans the filesystem. You can disable discovery or
|
|
38
|
+
> override any path via [config](#configuration); a generated manifest can replace runtime scanning
|
|
39
|
+
> in production.
|
|
40
|
+
|
|
41
|
+
## Models — app/models/
|
|
42
|
+
|
|
43
|
+
Every `Model` subclass under `app/models/` is registered automatically:
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
// app/models/User.ts
|
|
47
|
+
import { Model, column, hasMany } from "@zerotal/orm";
|
|
48
|
+
import { Post } from "./Post.ts";
|
|
49
|
+
|
|
50
|
+
export class User extends Model {
|
|
51
|
+
@column() name!: string;
|
|
52
|
+
@column() email!: string;
|
|
53
|
+
@hasMany(() => Post, { foreignKey: "user_id" }) posts!: Post[];
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
No `@table` needed. The table name is derived by convention — `pluralize(snake(ClassName))`:
|
|
58
|
+
|
|
59
|
+
| Class | Table |
|
|
60
|
+
| ---------- | ------------ |
|
|
61
|
+
| `User` | `users` |
|
|
62
|
+
| `BlogPost` | `blog_posts` |
|
|
63
|
+
| `Category` | `categories` |
|
|
64
|
+
| `Person` | `people` |
|
|
65
|
+
|
|
66
|
+
Override the name (or set timestamps/soft-deletes) with `@table` whenever you need to:
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
// app/models/Account.ts
|
|
70
|
+
@table("legacy_accounts", { softDeletes: true })
|
|
71
|
+
export class Account extends Model {
|
|
72
|
+
/* … */
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
> **Warning** — Use real fields for convention models. Declare columns as `@column() name!: string`, not
|
|
77
|
+
> `@column() declare name: string`. The loader identifies a class's columns from a probe
|
|
78
|
+
> instance's own fields, and `declare` fields are erased at runtime. (With an explicit `@table`,
|
|
79
|
+
> either form works.)
|
|
80
|
+
>
|
|
81
|
+
> Models that ship **inside packages** (not under an app's `app/models/`) and models defined
|
|
82
|
+
> **inline in tests** are not auto-discovered — they keep using `@table`.
|
|
83
|
+
|
|
84
|
+
## Observers — app/observers/
|
|
85
|
+
|
|
86
|
+
`XObserver` is attached to model `X` automatically (the `Observer` suffix is stripped and matched
|
|
87
|
+
against the discovered models):
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
// app/observers/UserObserver.ts
|
|
91
|
+
import type { ModelObserver } from "@zerotal/orm";
|
|
92
|
+
import type { User } from "../models/User.ts";
|
|
93
|
+
|
|
94
|
+
export class UserObserver implements ModelObserver<User> {
|
|
95
|
+
creating(user: User) {
|
|
96
|
+
user.uuid = crypto.randomUUID();
|
|
97
|
+
}
|
|
98
|
+
created(user: User) {
|
|
99
|
+
/* … */
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Override the target with `static model = SomeModel` when the name doesn't match.
|
|
105
|
+
|
|
106
|
+
## Policies — app/policies/
|
|
107
|
+
|
|
108
|
+
`XPolicy` is registered with the Gate for model `X`:
|
|
109
|
+
|
|
110
|
+
```typescript
|
|
111
|
+
// app/policies/PostPolicy.ts
|
|
112
|
+
import { Policy } from "@zerotal/auth";
|
|
113
|
+
import type { Post } from "../models/Post.ts";
|
|
114
|
+
import type { User } from "../models/User.ts";
|
|
115
|
+
|
|
116
|
+
export class PostPolicy extends Policy<Post> {
|
|
117
|
+
update(user: User, post: Post) {
|
|
118
|
+
return post.userId === user.id;
|
|
119
|
+
}
|
|
120
|
+
delete(user: User, post: Post) {
|
|
121
|
+
return post.userId === user.id;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Authorization (`Gate.allows("update", post)`, `ctx.authorize(...)`) resolves the policy from the
|
|
127
|
+
model's class automatically. Override the target with `static model = Post`.
|
|
128
|
+
|
|
129
|
+
## Events & listeners — app/events/, app/listeners/
|
|
130
|
+
|
|
131
|
+
A listener declares the event(s) it handles via `static listens`:
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
// app/events/UserRegistered.ts
|
|
135
|
+
export class UserRegistered {
|
|
136
|
+
constructor(public user: User) {}
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
```typescript
|
|
141
|
+
// app/listeners/SendWelcomeEmail.ts
|
|
142
|
+
import { UserRegistered } from "../events/UserRegistered.ts";
|
|
143
|
+
|
|
144
|
+
export class SendWelcomeEmail {
|
|
145
|
+
static listens = UserRegistered; // or an array: [UserRegistered, ...]
|
|
146
|
+
async handle(e: UserRegistered) {
|
|
147
|
+
await Mail.send(new WelcomeMail(e.user));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The loader binds each listener on the app event bus — no `Emitter.on(...)` wiring. Classes under
|
|
153
|
+
`app/events/` need no registration of their own; importing them is the whole effect (it bundles
|
|
154
|
+
them and runs any module-level side effects before listeners bind).
|
|
155
|
+
|
|
156
|
+
## Model events
|
|
157
|
+
|
|
158
|
+
A model can map its lifecycle events to event classes, which are dispatched on the bus when they
|
|
159
|
+
fire — listeners then react with no coupling to the model:
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
// app/models/Order.ts
|
|
163
|
+
export class Order extends Model {
|
|
164
|
+
static dispatchesEvents = { created: OrderPlaced, deleted: OrderCancelled };
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Keys are the lifecycle event names: `creating`, `created`, `updating`, `updated`, `saving`,
|
|
169
|
+
`saved`, `deleting`, `deleted`, `retrieved`. Each event class is constructed with the model
|
|
170
|
+
instance and emitted. Dispatch honours hook suppression, so factory seeding stays silent. If no
|
|
171
|
+
event bus is bound (ORM used standalone), it's a safe no-op.
|
|
172
|
+
|
|
173
|
+
> **Tip** — Observers and `dispatchesEvents` coexist. Use an **observer** to group all lifecycle
|
|
174
|
+
> handlers in one class, and **`dispatchesEvents`** for decoupled pub/sub with independent
|
|
175
|
+
> listeners.
|
|
176
|
+
|
|
177
|
+
## Providers — app/providers/
|
|
178
|
+
|
|
179
|
+
Any `ServiceProvider` under `app/providers/` is registered automatically — you don't list it in
|
|
180
|
+
`bootstrap/providers.ts`. Discovered providers run the full lifecycle
|
|
181
|
+
(`onRegister` → `onBooting` → `onBooted`), appended **after** the providers you registered
|
|
182
|
+
explicitly (so framework providers boot first), and de-duplicated if also listed by hand.
|
|
183
|
+
|
|
184
|
+
```typescript
|
|
185
|
+
// app/providers/AppServiceProvider.ts
|
|
186
|
+
import { ServiceProvider } from "zerotal";
|
|
187
|
+
|
|
188
|
+
export class AppServiceProvider extends ServiceProvider {
|
|
189
|
+
override onRegister() {
|
|
190
|
+
this.app.container.singleton("billing", () => new Billing());
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## Middleware — app/middleware/
|
|
196
|
+
|
|
197
|
+
Each middleware class under `app/middleware/` is registered as a **named group** under its class
|
|
198
|
+
name, so routes can reference it by string without importing it:
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
// app/middleware/EnsureSubscribed.ts
|
|
202
|
+
import { BaseMiddleware } from "zerotal";
|
|
203
|
+
|
|
204
|
+
export class EnsureSubscribed extends BaseMiddleware<{}> {
|
|
205
|
+
async handle(ctx, next) {
|
|
206
|
+
/* … */ return next(ctx);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
// routes/web.ts — reference by class name
|
|
213
|
+
Router.group({ middleware: ["EnsureSubscribed"] }, () => {
|
|
214
|
+
/* … */
|
|
215
|
+
});
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Middleware is **not** applied globally by default. Opt a class into the global pipeline with
|
|
219
|
+
`static global = true`:
|
|
220
|
+
|
|
221
|
+
```typescript
|
|
222
|
+
// app/middleware/RequestId.ts
|
|
223
|
+
export class RequestId extends BaseMiddleware<{}> {
|
|
224
|
+
static global = true; // runs on every request
|
|
225
|
+
async handle(ctx, next) {
|
|
226
|
+
/* … */ return next(ctx);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## Services — app/services/
|
|
232
|
+
|
|
233
|
+
Service classes under `app/services/` auto-register with the container, so `App.make(MyService)`
|
|
234
|
+
resolves them with the lifetime they declare. A class opts into a lifetime with a `static lifetime`
|
|
235
|
+
flag:
|
|
236
|
+
|
|
237
|
+
```typescript
|
|
238
|
+
// app/services/UsersService.ts
|
|
239
|
+
import { GateService } from "@zerotal/auth";
|
|
240
|
+
|
|
241
|
+
export class UsersService {
|
|
242
|
+
static lifetime = "singleton"; // "singleton" | "scoped" | "transient"
|
|
243
|
+
constructor(private gate: GateService) {}
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
- `singleton` / `scoped` → bound with a factory that auto-wires a fresh instance via
|
|
248
|
+
`container.build()`.
|
|
249
|
+
- `transient` or no flag → nothing is registered; the container already auto-wires unregistered
|
|
250
|
+
classes on demand, so resolution still works (a new instance each time). Non-service exports
|
|
251
|
+
(types, helpers) are therefore ignored.
|
|
252
|
+
|
|
253
|
+
## Jobs — app/jobs/
|
|
254
|
+
|
|
255
|
+
Queue job classes under `app/jobs/` are imported at boot, which triggers their
|
|
256
|
+
`JobRegistry.register()` self-registration — so dispatch works without a manual import or the
|
|
257
|
+
generated jobs barrel (the barrel is still used for production workers).
|
|
258
|
+
|
|
259
|
+
```typescript
|
|
260
|
+
// app/jobs/NotifyFollowersJob.ts
|
|
261
|
+
import { Job } from "@zerotal/queue";
|
|
262
|
+
|
|
263
|
+
export class NotifyFollowersJob extends Job {
|
|
264
|
+
override readonly queue = "notifications";
|
|
265
|
+
override readonly maxAttempts = 5;
|
|
266
|
+
|
|
267
|
+
constructor(private readonly postId: number) {
|
|
268
|
+
super();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Constructor arguments must be serialised to survive the queue.
|
|
272
|
+
override payload(): Record<string, unknown> {
|
|
273
|
+
return { postId: this.postId };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async handle(): Promise<void> {
|
|
277
|
+
// …
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Dispatch it from anywhere without importing the class into a barrel:
|
|
283
|
+
|
|
284
|
+
```typescript
|
|
285
|
+
// in a controller
|
|
286
|
+
await Queue.dispatch(new NotifyFollowersJob(post.id));
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
The registration key is the class name, so two jobs may not share one — see
|
|
290
|
+
[Queue](/docs/queue) for the full lifecycle.
|
|
291
|
+
|
|
292
|
+
## Schedules — app/schedules/
|
|
293
|
+
|
|
294
|
+
Classes under `app/schedules/` that extend the `Schedule` base class are instantiated and
|
|
295
|
+
registered with the scheduler at boot. Each declares its cadence (a `cron` string or the fluent
|
|
296
|
+
`frequency()` method) and its work (`handle()`); the loader translates the class's declarative
|
|
297
|
+
settings into a scheduled task.
|
|
298
|
+
|
|
299
|
+
```typescript
|
|
300
|
+
// app/schedules/SendDailyReports.ts
|
|
301
|
+
import { Schedule } from "@zerotal/scheduler";
|
|
302
|
+
|
|
303
|
+
export class SendDailyReports extends Schedule {
|
|
304
|
+
cron = "0 8 * * *";
|
|
305
|
+
withoutOverlapping = true;
|
|
306
|
+
async handle() {
|
|
307
|
+
await Queue.dispatch(new SendReportsJob());
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
This concern runs in the **worker** environment (to execute tasks) and **console** (so
|
|
313
|
+
`schedule:list` can enumerate them); it never runs in `web`. Contributed by `SchedulerProvider`.
|
|
314
|
+
See [Scheduler](/docs/scheduler) for the full `Schedule` settings reference.
|
|
315
|
+
|
|
316
|
+
## Validators — app/validators/
|
|
317
|
+
|
|
318
|
+
Files under `app/validators/` are auto-imported at boot, so any module-level registration they
|
|
319
|
+
perform is in place before the first request. This is where a shared `FormRequest` base class or a
|
|
320
|
+
rule set used across several requests belongs:
|
|
321
|
+
|
|
322
|
+
```typescript
|
|
323
|
+
// app/validators/StorePostRequest.ts
|
|
324
|
+
import { FormRequest } from "@zerotal/validator";
|
|
325
|
+
|
|
326
|
+
export class StorePostRequest extends FormRequest {
|
|
327
|
+
rules(r) {
|
|
328
|
+
return {
|
|
329
|
+
title: r.string().min(3).max(120),
|
|
330
|
+
body: r.string().min(1),
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
The auto-import matters for anything with a **side-effect at module scope**. A `FormRequest`
|
|
337
|
+
subclass imported by its controller would load anyway; a module that registers something on
|
|
338
|
+
import only runs because this concern imports it.
|
|
339
|
+
|
|
340
|
+
## Auto-migration
|
|
341
|
+
|
|
342
|
+
Once models are registered at boot, Zerotal can sync the schema additively — create missing tables
|
|
343
|
+
and add missing columns to match your models (TypeORM-style `synchronize`):
|
|
344
|
+
|
|
345
|
+
```typescript
|
|
346
|
+
// config/database.ts
|
|
347
|
+
import { DatabaseConfig } from "@zerotal/orm";
|
|
348
|
+
|
|
349
|
+
export default DatabaseConfig({
|
|
350
|
+
url: env("DATABASE_URL", "sqlite://./database.sqlite"),
|
|
351
|
+
synchronize: true, // dev convenience
|
|
352
|
+
});
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
- **Opt-in:** off unless enabled (like TypeORM), and **hard-off in `production`** regardless —
|
|
356
|
+
there you run `migrate` with generated migration files. A common setup enables it only for
|
|
357
|
+
tests/local via env (`synchronize: env("APP_ENV") !== "production"`).
|
|
358
|
+
- **Additive by default:** `true` creates missing tables and adds missing columns; it never
|
|
359
|
+
drops or alters existing ones.
|
|
360
|
+
- **Disruptive opt-in:** pass `synchronize: { enabled: true, disruptive: true }` to also drop
|
|
361
|
+
columns no model declares anymore. This destroys their data (and logs a warning per drop), so
|
|
362
|
+
keep it to local/test. See [Migrations → Disruptive sync](/docs/migrations#disruptive-sync).
|
|
363
|
+
|
|
364
|
+
> **Danger** — Disruptive sync drops columns and the data they hold. Never enable it in production;
|
|
365
|
+
> the concern is hard-off there regardless, but keep it scoped to local/test even so.
|
|
366
|
+
|
|
367
|
+
## Configuration
|
|
368
|
+
|
|
369
|
+
Auto-discovery is configured under the `conventions` key in `config/app.ts`. `AppConfig()` fills
|
|
370
|
+
in every default, so you only set what you want to change:
|
|
371
|
+
|
|
372
|
+
```typescript
|
|
373
|
+
// config/app.ts
|
|
374
|
+
import { env } from "zerotal";
|
|
375
|
+
import { AppConfig } from "zerotal/config";
|
|
376
|
+
|
|
377
|
+
export default AppConfig({
|
|
378
|
+
name: "Example",
|
|
379
|
+
url: env("APP_URL", "http://localhost:3000"),
|
|
380
|
+
key: env("APP_KEY", "changeme-in-production"),
|
|
381
|
+
|
|
382
|
+
// Defaults shown — omit entirely unless you want to change them.
|
|
383
|
+
conventions: {
|
|
384
|
+
enabled: true,
|
|
385
|
+
paths: {
|
|
386
|
+
providers: "app/providers",
|
|
387
|
+
middleware: "app/middleware",
|
|
388
|
+
models: "app/models",
|
|
389
|
+
observers: "app/observers",
|
|
390
|
+
policies: "app/policies",
|
|
391
|
+
listeners: "app/listeners",
|
|
392
|
+
events: "app/events",
|
|
393
|
+
jobs: "app/jobs",
|
|
394
|
+
schedules: "app/schedules",
|
|
395
|
+
validators: "app/validators",
|
|
396
|
+
commands: "app/commands",
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
| Field | Required | Default | Description |
|
|
403
|
+
| --------- | -------- | ---------------------- | ---------------------------------------------------------- |
|
|
404
|
+
| `enabled` | no | `true` | Master switch for convention-based auto-registration. |
|
|
405
|
+
| `paths` | no | the `app/*` dirs above | Per-concern directory overrides, relative to the app root. |
|
|
406
|
+
|
|
407
|
+
Set `conventions.enabled: false` to opt out entirely and wire everything manually. Path overrides
|
|
408
|
+
let you relocate any concern. Files starting with `_` and `*.test.ts` / `*.test.tsx` /
|
|
409
|
+
`*.spec.ts` / `*.d.ts` are always skipped.
|
|
410
|
+
|
|
411
|
+
## Custom concerns
|
|
412
|
+
|
|
413
|
+
Discovery is extensible. A provider can contribute its own concern descriptor via
|
|
414
|
+
`app.registerConcern(...)`:
|
|
415
|
+
|
|
416
|
+
```typescript
|
|
417
|
+
// app/providers/AppServiceProvider.ts — inside onRegister/onBooting
|
|
418
|
+
this.app.registerConcern({
|
|
419
|
+
name: "validators",
|
|
420
|
+
order: 60,
|
|
421
|
+
dir: "app/validators",
|
|
422
|
+
register(mod, ctx) {
|
|
423
|
+
for (const exported of Object.values(mod)) {
|
|
424
|
+
/* register exported as needed */
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
});
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
`order` controls sequencing (lower runs first). A `run(ctx)` hook (no `dir`) defines a one-shot
|
|
431
|
+
step that runs after the scanned concerns at the same point in the ordering — this is how
|
|
432
|
+
auto-migration is wired (`order: 100`, `run`-only). Restrict a concern to certain runtimes with
|
|
433
|
+
`envs` (e.g. `envs: ["worker", "console"]`, as `schedules` does).
|
|
434
|
+
|
|
435
|
+
## Reference
|
|
436
|
+
|
|
437
|
+
The concerns that run in the convention phase, in `order`. `events`, `services`, `listeners`,
|
|
438
|
+
`jobs`, and `validators` are owned by core; the rest are contributed by their packages.
|
|
439
|
+
|
|
440
|
+
| Concern | Directory | Order | Runs in | Contributed by |
|
|
441
|
+
| -------------- | ---------------- | ----- | --------------- | -------------------- |
|
|
442
|
+
| `events` | `app/events` | 5 | all | core |
|
|
443
|
+
| `models` | `app/models` | 10 | all | `@zerotal/orm` |
|
|
444
|
+
| `services` | `app/services` | 10 | all | core |
|
|
445
|
+
| `observers` | `app/observers` | 20 | all | `@zerotal/orm` |
|
|
446
|
+
| `policies` | `app/policies` | 30 | all | `@zerotal/auth` |
|
|
447
|
+
| `listeners` | `app/listeners` | 40 | all | core |
|
|
448
|
+
| `jobs` | `app/jobs` | 50 | all | core |
|
|
449
|
+
| `schedules` | `app/schedules` | 55 | worker, console | `@zerotal/scheduler` |
|
|
450
|
+
| `validators` | `app/validators` | 60 | all | core |
|
|
451
|
+
| `auto-migrate` | _(run-only)_ | 100 | non-production | `@zerotal/orm` |
|
|
452
|
+
|
|
453
|
+
`providers` (`app/providers`) and `middleware` (`app/middleware`) are not convention-phase concerns
|
|
454
|
+
— they are discovered earlier in the boot sequence (see [Mental model](#mental-model)).
|
|
455
|
+
|
|
456
|
+
`commands` (`app/commands`) is discovered by the CLI's command runner rather than the convention
|
|
457
|
+
phase — console, worker, and test environments only, since HTTP boot has no use for parsing CLI
|
|
458
|
+
files. It honours `conventions.enabled` and the `paths.commands` override like the rest. See
|
|
459
|
+
[Commands](/docs/commands#auto-discovery).
|
|
460
|
+
|
|
461
|
+
## Next steps
|
|
462
|
+
|
|
463
|
+
- [Structure](/docs/structure) — see how the `app/*` directories fit the project layout.
|
|
464
|
+
- [Providers](/docs/providers) — register and boot services explicitly when convention isn't enough.
|
|
465
|
+
- [Config system](/docs/config-system) — how `config/*.ts` files are loaded and merged.
|
|
466
|
+
- [Migrations](/docs/migrations) — generate schema changes instead of relying on `synchronize`.
|
package/docs/cookies.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Cookies
|
|
3
|
+
description: Read and write raw HTTP cookies via the request/response headers when the session isn't the right fit.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Cookies
|
|
7
|
+
|
|
8
|
+
Most state you'd reach for cookies for is better handled by the
|
|
9
|
+
[session](/docs/session) — it stores data in a **signed, `HttpOnly`** cookie for you.
|
|
10
|
+
When you need to read or set a raw cookie directly, you work with the standard
|
|
11
|
+
`Request`/`Response` headers on the [HTTP context](/docs/context).
|
|
12
|
+
|
|
13
|
+
## Which should I use?
|
|
14
|
+
|
|
15
|
+
- **Session** — the default. User state, flash messages, anything sensitive or
|
|
16
|
+
tamper-prone. The [session](/docs/session) driver signs and encrypts it for you.
|
|
17
|
+
- **Raw cookie** — small, non-sensitive client preferences the browser must read
|
|
18
|
+
too (a theme toggle, a dismissed-banner flag). Reach for the manual approach
|
|
19
|
+
below only here.
|
|
20
|
+
|
|
21
|
+
> **Danger** — A plain `Set-Cookie` value is fully client-visible and editable.
|
|
22
|
+
> Zerotal signs the session cookie so it can't be tampered with; for a tamper-proof
|
|
23
|
+
> value of your own, store it in the session or sign it yourself with
|
|
24
|
+
> [`Url.sign`](/docs/encryption#signed-urls) or [encryption](/docs/encryption) rather
|
|
25
|
+
> than trusting a raw cookie.
|
|
26
|
+
|
|
27
|
+
## Reading a cookie
|
|
28
|
+
|
|
29
|
+
Cookies arrive in the request's `Cookie` header. Read and parse it from the context:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
// in a controller
|
|
33
|
+
import type { HttpContext } from "zerotal";
|
|
34
|
+
|
|
35
|
+
function readCookie(ctx: HttpContext, name: string): string | undefined {
|
|
36
|
+
const header = ctx.request.headers.get("Cookie") ?? "";
|
|
37
|
+
for (const part of header.split(";")) {
|
|
38
|
+
const [k, ...v] = part.trim().split("=");
|
|
39
|
+
if (k === name) return decodeURIComponent(v.join("="));
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const theme = readCookie(ctx, "theme") ?? "light";
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Setting a cookie
|
|
48
|
+
|
|
49
|
+
Set the response with a `ctx` helper, then append a `Set-Cookie` header to it. Use
|
|
50
|
+
`append` (not `set`) so multiple cookies can be sent in one response:
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
// in a controller
|
|
54
|
+
ctx.json({ ok: true }); // assigns ctx.response
|
|
55
|
+
|
|
56
|
+
ctx.response!.headers.append(
|
|
57
|
+
"Set-Cookie",
|
|
58
|
+
`theme=dark; Path=/; Max-Age=${60 * 60 * 24 * 365}; SameSite=Lax`,
|
|
59
|
+
);
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
> **Note** — `ctx.json()` returns `void` — it sets `ctx.response` for you. Reach for
|
|
63
|
+
> the response object via `ctx.response` after calling a response helper; don't
|
|
64
|
+
> assign its return value.
|
|
65
|
+
|
|
66
|
+
### Recommended attributes
|
|
67
|
+
|
|
68
|
+
| Attribute | Why |
|
|
69
|
+
| ------------------- | ------------------------------------------------------------------ |
|
|
70
|
+
| `Path=/` | Make the cookie apply site-wide. |
|
|
71
|
+
| `HttpOnly` | Hide it from JavaScript — use for anything sensitive. |
|
|
72
|
+
| `Secure` | Only send over HTTPS. Enable in production. |
|
|
73
|
+
| `SameSite=Lax` | Sensible CSRF-resistant default; use `Strict` for extra isolation. |
|
|
74
|
+
| `Max-Age=<seconds>` | Lifetime. Omit for a session cookie that clears on browser close. |
|
|
75
|
+
|
|
76
|
+
To **delete** a cookie, set it again with `Max-Age=0`:
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
// in a controller
|
|
80
|
+
ctx.response!.headers.append("Set-Cookie", "theme=; Path=/; Max-Age=0");
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Framework-managed cookies
|
|
84
|
+
|
|
85
|
+
You rarely set cookies by hand — two parts of the framework manage their own:
|
|
86
|
+
|
|
87
|
+
- **Session cookie** — the [session](/docs/session) driver stores the whole session
|
|
88
|
+
in a single cookie, **signed with HMAC-SHA256** and flagged `HttpOnly`,
|
|
89
|
+
`SameSite=Lax` (and `Secure` in production). Its name (`cookie`) and lifetime
|
|
90
|
+
(`lifetime`) come from `config/session.ts`. Put user state in the session rather
|
|
91
|
+
than rolling your own signed cookie.
|
|
92
|
+
- **`XSRF-TOKEN` cookie** — [`CsrfMiddleware`](/docs/csrf) sets this readable
|
|
93
|
+
(non-`HttpOnly`) cookie after every request so Axios/Inertia can echo it back as
|
|
94
|
+
the `X-XSRF-TOKEN` header.
|
|
95
|
+
|
|
96
|
+
## Next steps
|
|
97
|
+
|
|
98
|
+
- [Session](/docs/session) — signed, `HttpOnly` cookie-backed state (the usual choice).
|
|
99
|
+
- [CSRF Protection](/docs/csrf) — the `XSRF-TOKEN` cookie.
|
|
100
|
+
- [HTTP Context](/docs/context) — the request/response objects you read and write.
|
|
101
|
+
- [Encryption](/docs/encryption) — sign or encrypt your own cookie values.
|