@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/monitor.md
CHANGED
|
@@ -136,7 +136,7 @@ Everything is driven by core's `FrameworkEvents` bus, the same synchronous subst
|
|
|
136
136
|
|
|
137
137
|
For anything that does not flow through the bus — a deploy marker, or a third-party call you make without the `Http` client — the `Monitor` facade records it directly and takes precedence. Every `Monitor.*` call is a safe no-op until the provider boots, so you can call it from anywhere without guarding.
|
|
138
138
|
|
|
139
|
-
```ts
|
|
139
|
+
```ts fragment
|
|
140
140
|
import { Monitor } from "@zerotal/monitor";
|
|
141
141
|
|
|
142
142
|
Monitor.recordDeploy(gitSha);
|
|
@@ -187,7 +187,7 @@ export default MonitorConfig({
|
|
|
187
187
|
|
|
188
188
|
The `auth` callback receives the authenticated user (or `undefined`) and returns whether to allow access. The default permits access only outside production, which is safe for local development but means **you must set an `auth` gate before deploying** — otherwise the panel, which exposes request payloads, user identities, and logs, is unreachable in production by default but wide open the moment you flip the environment. Gate it to an admin role:
|
|
189
189
|
|
|
190
|
-
```ts
|
|
190
|
+
```ts fragment
|
|
191
191
|
auth: (user) => user?.role === "admin",
|
|
192
192
|
```
|
|
193
193
|
|
|
@@ -197,7 +197,7 @@ The same gate protects the JSON export route. The Prometheus endpoint is deliber
|
|
|
197
197
|
|
|
198
198
|
The panel evaluates threshold alerts every fifteen seconds against the live snapshot. Out of the box it watches four signals: the 5xx error rate, p95 latency, total pending jobs across queues, and transaction rollbacks. Tune the limits through `alertThresholds`:
|
|
199
199
|
|
|
200
|
-
```ts
|
|
200
|
+
```ts fragment
|
|
201
201
|
alertThresholds: { errorRatePct: 5, p95Ms: 2000, queuePending: 500, rolledBackInWindow: 10 },
|
|
202
202
|
```
|
|
203
203
|
|
|
@@ -205,7 +205,7 @@ Alerts are edge-triggered — each one fires once when it crosses its threshold
|
|
|
205
205
|
|
|
206
206
|
When an alert fires it is logged, recorded so the Alerts tab can show it with its full context, and dispatched. There are two ways to be paged. The simplest is a webhook — set `alertWebhook` to a Slack-compatible URL and each firing is POSTed as JSON, no extra dependency. For richer routing, register a handler and wire it to notifications, PagerDuty, or anything else:
|
|
207
207
|
|
|
208
|
-
```ts
|
|
208
|
+
```ts fragment
|
|
209
209
|
import { onAlert } from "@zerotal/monitor";
|
|
210
210
|
|
|
211
211
|
onAlert((alert) => {
|
|
@@ -268,7 +268,7 @@ The panel owns the shell, the time-range selector, the storage and the retention
|
|
|
268
268
|
|
|
269
269
|
A section is _described_, not rendered. The contributor returns stats and tables; the panel draws them. That keeps the panel coherent no matter who contributed a section, and means the contributing package needs no JSX and no dependency on `@zerotal/monitor`:
|
|
270
270
|
|
|
271
|
-
```ts
|
|
271
|
+
```ts fragment
|
|
272
272
|
// In a contributing provider's onBooting()
|
|
273
273
|
interface MonitorHost {
|
|
274
274
|
enabled(id: string): boolean;
|
|
@@ -311,7 +311,7 @@ This is the other half of how the recorders already work. A package writes its m
|
|
|
311
311
|
|
|
312
312
|
To keep a contributor installed but drop its section, name it in `sections`:
|
|
313
313
|
|
|
314
|
-
```ts
|
|
314
|
+
```ts fragment
|
|
315
315
|
// config/monitor.ts
|
|
316
316
|
export default MonitorConfig({
|
|
317
317
|
sections: { scheduler: false },
|
package/docs/notifications.md
CHANGED
|
@@ -137,7 +137,7 @@ sends one real message and prints whatever the server said.
|
|
|
137
137
|
Extend `Notification`, declare `channels()`, then implement a `to*()` method for
|
|
138
138
|
each declared channel:
|
|
139
139
|
|
|
140
|
-
```ts
|
|
140
|
+
```ts fragment
|
|
141
141
|
// app/notifications/OrderShippedNotification.ts
|
|
142
142
|
import { Notification, MailMessage } from "@zerotal/notifications";
|
|
143
143
|
import type { Notifiable } from "@zerotal/notifications";
|
|
@@ -188,7 +188,7 @@ export class OrderShippedNotification extends Notification {
|
|
|
188
188
|
`channels()` receives the recipient, so a single notification can respect each
|
|
189
189
|
person's preferences instead of forcing every recipient down the same path:
|
|
190
190
|
|
|
191
|
-
```ts
|
|
191
|
+
```ts fragment
|
|
192
192
|
// app/notifications/OrderShippedNotification.ts
|
|
193
193
|
channels(user: Notifiable) {
|
|
194
194
|
// Everyone gets the inbox copy; how they're alerted is their choice.
|
|
@@ -202,7 +202,7 @@ common case, and `channels()` with no arguments stays valid.
|
|
|
202
202
|
A recipient can also redirect an individual channel without any notification
|
|
203
203
|
knowing about it, by implementing `routeNotificationFor`:
|
|
204
204
|
|
|
205
|
-
```ts
|
|
205
|
+
```ts fragment
|
|
206
206
|
// app/models/User.ts
|
|
207
207
|
routeNotificationFor(channel: string) {
|
|
208
208
|
// Invoices go to the billing contact; everything else to the usual address.
|
|
@@ -221,7 +221,7 @@ afterwards as a `NotificationDispatchError`. A Slack webhook returning a 500 doe
|
|
|
221
221
|
not cost the recipient the email and the stored row that were declared alongside
|
|
222
222
|
it.
|
|
223
223
|
|
|
224
|
-
```ts
|
|
224
|
+
```ts fragment
|
|
225
225
|
// in a controller or service
|
|
226
226
|
try {
|
|
227
227
|
await Notify.send(user, new OrderShippedNotification(order));
|
|
@@ -289,7 +289,7 @@ export class User extends AuthUser.using(Notifiable) {
|
|
|
289
289
|
}
|
|
290
290
|
```
|
|
291
291
|
|
|
292
|
-
```ts
|
|
292
|
+
```ts fragment
|
|
293
293
|
// in a controller or service
|
|
294
294
|
await user.notify(new OrderShippedNotification(order)); // send now
|
|
295
295
|
await user.notifyLater(new OrderShippedNotification(order)); // queue for background
|
|
@@ -301,7 +301,7 @@ await user.markNotificationsAsRead();
|
|
|
301
301
|
|
|
302
302
|
### Via the Notify facade
|
|
303
303
|
|
|
304
|
-
```ts
|
|
304
|
+
```ts fragment
|
|
305
305
|
// in a controller or service
|
|
306
306
|
import { Notify } from "@zerotal/notifications";
|
|
307
307
|
|
|
@@ -315,7 +315,7 @@ await Notify.queue(user, new OrderShippedNotification(order)); // queue for back
|
|
|
315
315
|
independent: one failing does not stop the rest, and the errors are reported
|
|
316
316
|
together at the end.
|
|
317
317
|
|
|
318
|
-
```ts
|
|
318
|
+
```ts fragment
|
|
319
319
|
// in a controller or service
|
|
320
320
|
const admins = await User.where("role", "admin").get();
|
|
321
321
|
await Notify.sendMany(admins, new LowStockNotification(product));
|
|
@@ -326,7 +326,7 @@ await Notify.sendMany(admins, new LowStockNotification(product));
|
|
|
326
326
|
Some notifications go to a destination rather than a user — an on-call address, a
|
|
327
327
|
webhook, a number typed into a form. `route()` takes the destinations directly:
|
|
328
328
|
|
|
329
|
-
```ts
|
|
329
|
+
```ts fragment
|
|
330
330
|
// in a controller or service
|
|
331
331
|
await Notify.route({ mail: "ops@acme.test" }).notify(new DeployFinished(build));
|
|
332
332
|
|
|
@@ -343,7 +343,7 @@ normally declares only transport channels.
|
|
|
343
343
|
|
|
344
344
|
### Via the NotificationManager directly
|
|
345
345
|
|
|
346
|
-
```ts
|
|
346
|
+
```ts fragment
|
|
347
347
|
// in a service
|
|
348
348
|
import { NotificationManager } from "@zerotal/notifications";
|
|
349
349
|
import { Application } from "zerotal";
|
|
@@ -361,7 +361,7 @@ to the notifiable's `email`, so you rarely set `to()`. The mail channel is
|
|
|
361
361
|
built-in and works on the `log` driver out of the box; switch to `smtp` or
|
|
362
362
|
`resend` in `config/notifications.ts`.
|
|
363
363
|
|
|
364
|
-
```ts
|
|
364
|
+
```ts fragment
|
|
365
365
|
// in a Notification
|
|
366
366
|
import { MailMessage } from "@zerotal/notifications";
|
|
367
367
|
import type { Notifiable } from "@zerotal/notifications";
|
|
@@ -379,7 +379,7 @@ Attach files with `attach()` when you already have the bytes, or `attachFile()`
|
|
|
379
379
|
to read one from disk. `embed()` places an image in the body rather than listing
|
|
380
380
|
it as a download — reference it from your HTML as `cid:the-id`:
|
|
381
381
|
|
|
382
|
-
```ts
|
|
382
|
+
```ts fragment
|
|
383
383
|
// in a Notification
|
|
384
384
|
async toMail(_n: Notifiable): Promise<MailMessage> {
|
|
385
385
|
return (await new MailMessage()
|
|
@@ -416,7 +416,7 @@ Read and mark stored notifications through the [mixin helpers](#via-the-notifiab
|
|
|
416
416
|
Inbox reads return the 100 most recent rows unless told otherwise. Pass a limit
|
|
417
417
|
and offset to page, or `limit: 0` for everything:
|
|
418
418
|
|
|
419
|
-
```ts
|
|
419
|
+
```ts fragment
|
|
420
420
|
// in a controller
|
|
421
421
|
const page = await user.notifications({ limit: 20, offset: 40 });
|
|
422
422
|
const badge = await user.unreadNotificationCount(); // counts without loading rows
|
|
@@ -430,7 +430,7 @@ past an age threshold; add `--all` to include unread ones. Schedule it.
|
|
|
430
430
|
|
|
431
431
|
Implement `toSlack(notifiable)` returning a `SlackMessage`:
|
|
432
432
|
|
|
433
|
-
```ts
|
|
433
|
+
```ts fragment
|
|
434
434
|
// in a Notification
|
|
435
435
|
import type { SlackMessage } from "@zerotal/notifications";
|
|
436
436
|
|
|
@@ -453,7 +453,7 @@ channel raises a `NotificationChannelNotConfiguredError` naming the notification
|
|
|
453
453
|
Implement `toSms(notifiable)` returning an `SmsMessage`. Supported drivers:
|
|
454
454
|
`twilio` and `vonage`.
|
|
455
455
|
|
|
456
|
-
```ts
|
|
456
|
+
```ts fragment
|
|
457
457
|
// in a Notification
|
|
458
458
|
import type { SmsMessage } from "@zerotal/notifications";
|
|
459
459
|
|
|
@@ -474,7 +474,7 @@ Push a notification to a connected client in real time via
|
|
|
474
474
|
returning a `BroadcastMessage` (or a plain data object), and optionally
|
|
475
475
|
`broadcastType()` to set the wire `type`:
|
|
476
476
|
|
|
477
|
-
```ts
|
|
477
|
+
```ts fragment
|
|
478
478
|
// app/notifications/InvoicePaid.ts
|
|
479
479
|
import { Notification, BroadcastMessage } from "@zerotal/notifications";
|
|
480
480
|
import type { Notifiable } from "@zerotal/notifications";
|
|
@@ -504,7 +504,7 @@ The broadcast channel works like this:
|
|
|
504
504
|
`private-notifications.{id}` by default. Override per notifiable with
|
|
505
505
|
`receivesBroadcastNotificationsOn(): string`:
|
|
506
506
|
|
|
507
|
-
```ts
|
|
507
|
+
```ts fragment
|
|
508
508
|
// app/models/User.ts
|
|
509
509
|
class User extends Model {
|
|
510
510
|
receivesBroadcastNotificationsOn() {
|
|
@@ -521,14 +521,14 @@ The broadcast channel works like this:
|
|
|
521
521
|
stored notification.
|
|
522
522
|
- **Authorize** the per-user channel in `routes/channels.ts`:
|
|
523
523
|
|
|
524
|
-
```ts
|
|
524
|
+
```ts fragment
|
|
525
525
|
// routes/channels.ts
|
|
526
526
|
Broadcast.channel("notifications.[id]", (user, id) => String(user.id) === id);
|
|
527
527
|
```
|
|
528
528
|
|
|
529
529
|
- **Client** (any Pusher-compatible client):
|
|
530
530
|
|
|
531
|
-
```ts
|
|
531
|
+
```ts fragment
|
|
532
532
|
// in your frontend
|
|
533
533
|
Socket.private(`notifications.${userId}`).listen("notification", (n) => {
|
|
534
534
|
console.log(n.type, n);
|
|
@@ -541,7 +541,7 @@ A broadcast goes out inline, which is the point of the channel. When one fans ou
|
|
|
541
541
|
widely enough that the request should not wait for it, `.onQueue(name)` hands it
|
|
542
542
|
to a worker instead — at the cost of arriving whenever that worker picks it up.
|
|
543
543
|
|
|
544
|
-
```ts
|
|
544
|
+
```ts fragment
|
|
545
545
|
// in a Notification
|
|
546
546
|
toBroadcast(_n: Notifiable) {
|
|
547
547
|
return new BroadcastMessage({ id: this.report.id }).onQueue("broadcasts");
|
|
@@ -554,7 +554,7 @@ The five built-in channels are registered the same way yours are, so adding a
|
|
|
554
554
|
channel is not a special case. Register a factory under a name, and any
|
|
555
555
|
notification can declare it:
|
|
556
556
|
|
|
557
|
-
```ts
|
|
557
|
+
```ts fragment
|
|
558
558
|
// app/providers/DiscordChannelProvider.ts
|
|
559
559
|
import type { NotificationChannel, Notifiable } from "@zerotal/notifications";
|
|
560
560
|
|
|
@@ -576,7 +576,7 @@ const notifications = this.app.container.makeSync("notifications");
|
|
|
576
576
|
notifications.extend("discord", () => new DiscordChannel());
|
|
577
577
|
```
|
|
578
578
|
|
|
579
|
-
```ts
|
|
579
|
+
```ts fragment
|
|
580
580
|
// app/notifications/DeployFinished.ts
|
|
581
581
|
channels() {
|
|
582
582
|
return ["database", "discord"];
|
|
@@ -612,7 +612,7 @@ notification you queue.
|
|
|
612
612
|
`app/notifications/` are found automatically. One that lives elsewhere must
|
|
613
613
|
register itself:
|
|
614
614
|
|
|
615
|
-
```ts
|
|
615
|
+
```ts fragment
|
|
616
616
|
// app/domain/billing/InvoiceOverdue.ts
|
|
617
617
|
import { NotificationRegistry } from "@zerotal/notifications";
|
|
618
618
|
|
|
@@ -627,7 +627,7 @@ By default a notification's own enumerable fields are serialized, which covers a
|
|
|
627
627
|
constructor that assigns plain values. When it holds something JSON cannot carry
|
|
628
628
|
— a model instance, a `Map`, a closure — say how to shrink and rebuild it:
|
|
629
629
|
|
|
630
|
-
```ts
|
|
630
|
+
```ts fragment
|
|
631
631
|
// app/notifications/InvoicePaid.ts
|
|
632
632
|
export class InvoicePaid extends Notification {
|
|
633
633
|
constructor(private invoice: Invoice) {
|
|
@@ -649,7 +649,7 @@ export class InvoicePaid extends Notification {
|
|
|
649
649
|
`NotificationFake` swaps the `"notifications"` container binding for an
|
|
650
650
|
in-memory recorder, so assertions run without hitting any real channel:
|
|
651
651
|
|
|
652
|
-
```ts
|
|
652
|
+
```ts fragment
|
|
653
653
|
// tests/orders.test.ts
|
|
654
654
|
import { NotificationFake } from "@zerotal/notifications";
|
|
655
655
|
import { describe, it, beforeEach, afterEach } from "bun:test";
|
package/docs/orm/casts.md
CHANGED
|
@@ -58,7 +58,7 @@ export class Post extends Model {
|
|
|
58
58
|
`json` and `array` encode on write and parse on read, in both directions, so a value
|
|
59
59
|
round-trips as the type you gave it — including a bare scalar:
|
|
60
60
|
|
|
61
|
-
```typescript
|
|
61
|
+
```typescript fragment
|
|
62
62
|
setting.value = "62812345678"; // stored as "62812345678", read back as a string
|
|
63
63
|
setting.value = "051001"; // a branch code keeps its leading zero
|
|
64
64
|
setting.value = { plan: "pro" }; // objects and arrays as you would expect
|
|
@@ -79,7 +79,7 @@ was a numeric string may come back as a number; coerce on read where it matters.
|
|
|
79
79
|
Reads and writes the value as a string with exactly `N` decimal places. Useful
|
|
80
80
|
for currency, where you want to avoid floating-point drift:
|
|
81
81
|
|
|
82
|
-
```typescript
|
|
82
|
+
```typescript fragment
|
|
83
83
|
// app/models/Product.ts
|
|
84
84
|
@column({ type: "number", cast: "decimal:2" }) price!: string;
|
|
85
85
|
// DB stores "9.99" — the model reads it back as the string "9.99".
|
|
@@ -94,7 +94,7 @@ for currency, where you want to avoid floating-point drift:
|
|
|
94
94
|
Behaves like `'datetime'` on read (constructs a [Carbon](/docs/carbon)) and
|
|
95
95
|
serializes to an ISO 8601 string on write:
|
|
96
96
|
|
|
97
|
-
```typescript
|
|
97
|
+
```typescript fragment
|
|
98
98
|
// app/models/Booking.ts
|
|
99
99
|
@column({ type: "datetime", cast: "immutable_datetime" }) lockedAt?: Carbon;
|
|
100
100
|
|
|
@@ -112,7 +112,7 @@ Stores and retrieves the raw enum value (the underlying string or number);
|
|
|
112
112
|
TypeScript narrows the property type. The cast itself is a pass-through, so pair
|
|
113
113
|
it with `enumValues` to document the enum:
|
|
114
114
|
|
|
115
|
-
```typescript
|
|
115
|
+
```typescript fragment
|
|
116
116
|
// app/models/Post.ts
|
|
117
117
|
enum Status {
|
|
118
118
|
Draft = "draft",
|
|
@@ -132,7 +132,7 @@ The column stores an opaque AES-256-GCM payload keyed by `APP_KEY`; the property
|
|
|
132
132
|
holds the value you assigned. Nothing in between — your code, validation,
|
|
133
133
|
`$dirty` — has to know:
|
|
134
134
|
|
|
135
|
-
```typescript
|
|
135
|
+
```typescript fragment
|
|
136
136
|
// app/models/Client.ts
|
|
137
137
|
@column("encrypted", { nullable: true }) idNumber?: string;
|
|
138
138
|
|
|
@@ -150,7 +150,7 @@ that ciphertext outgrows its plaintext.
|
|
|
150
150
|
|
|
151
151
|
For several columns at once, list them instead — it means exactly the same thing:
|
|
152
152
|
|
|
153
|
-
```typescript
|
|
153
|
+
```typescript fragment
|
|
154
154
|
class Client extends BaseModel {
|
|
155
155
|
static encryptable = ["idNumber", "passportNumber"];
|
|
156
156
|
}
|
|
@@ -160,7 +160,7 @@ A column in that list whose `@column({ type })` is `json` encrypts as
|
|
|
160
160
|
`encrypted:json` automatically, so the structure survives the round trip rather
|
|
161
161
|
than reaching the cipher as `"[object Object]"`.
|
|
162
162
|
|
|
163
|
-
Unlike [`hashable`](/docs/orm
|
|
163
|
+
Unlike [`hashable`](/docs/orm), this is reversible and does not touch the
|
|
164
164
|
instance: after `save()`, `client.idNumber` still reads as the plaintext you set.
|
|
165
165
|
`$dirty` therefore compares plaintext, and an unchanged column is not rewritten
|
|
166
166
|
with a fresh IV on every unrelated save.
|
|
@@ -195,7 +195,7 @@ one, and re-encrypt it on the next save, losing the original for good.
|
|
|
195
195
|
Pass an object with `get` and `set` functions for complete control over
|
|
196
196
|
serialization:
|
|
197
197
|
|
|
198
|
-
```typescript
|
|
198
|
+
```typescript fragment
|
|
199
199
|
// app/models/Place.ts
|
|
200
200
|
interface GeoPoint { lat: number; lng: number }
|
|
201
201
|
|
|
@@ -232,7 +232,7 @@ export class MoneyCast extends Cast<number> {
|
|
|
232
232
|
}
|
|
233
233
|
```
|
|
234
234
|
|
|
235
|
-
```typescript
|
|
235
|
+
```typescript fragment
|
|
236
236
|
// app/models/Invoice.ts
|
|
237
237
|
import { column } from "@zerotal/orm";
|
|
238
238
|
import { MoneyCast } from "../casts/MoneyCast.ts";
|
|
@@ -243,7 +243,7 @@ import { MoneyCast } from "../casts/MoneyCast.ts";
|
|
|
243
243
|
For JSON columns the ORM ships ready-made helpers that optionally hydrate the
|
|
244
244
|
parsed value into a class:
|
|
245
245
|
|
|
246
|
-
```typescript
|
|
246
|
+
```typescript fragment
|
|
247
247
|
// app/models/Customer.ts
|
|
248
248
|
import { column } from "@zerotal/orm";
|
|
249
249
|
import { json, objectOf, arrayOf } from "@zerotal/orm";
|
|
@@ -263,7 +263,7 @@ import { Address } from "../value-objects/Address.ts";
|
|
|
263
263
|
An alternative to `@column()` for columns you don't declare directly (e.g. from
|
|
264
264
|
an external schema, a view, or a generated table):
|
|
265
265
|
|
|
266
|
-
```typescript
|
|
266
|
+
```typescript fragment
|
|
267
267
|
// app/models/Post.ts
|
|
268
268
|
@table("posts")
|
|
269
269
|
export class Post extends Model {
|
|
@@ -300,7 +300,7 @@ does not mark the column dirty and won't be persisted on the next `save()`.
|
|
|
300
300
|
Enable `reactiveCasts` to make `json` and `array` columns use a reactive proxy
|
|
301
301
|
that tracks deep mutations:
|
|
302
302
|
|
|
303
|
-
```typescript
|
|
303
|
+
```typescript fragment
|
|
304
304
|
// app/models/Post.ts
|
|
305
305
|
@table("posts")
|
|
306
306
|
export class Post extends Model {
|
|
@@ -318,7 +318,7 @@ await post.save(); // persists the updated meta
|
|
|
318
318
|
|
|
319
319
|
Without `reactiveCasts`, replace the whole value to ensure dirty tracking:
|
|
320
320
|
|
|
321
|
-
```typescript
|
|
321
|
+
```typescript fragment
|
|
322
322
|
// in a controller
|
|
323
323
|
post.meta = { ...post.meta, views: (post.meta.views as number) + 1 };
|
|
324
324
|
await post.save();
|
|
@@ -359,7 +359,7 @@ Cast options accepted by `@column()`:
|
|
|
359
359
|
|
|
360
360
|
## Next steps
|
|
361
361
|
|
|
362
|
-
- [ORM](/docs/orm
|
|
362
|
+
- [ORM](/docs/orm) — defining models and columns.
|
|
363
363
|
- [Queries](/docs/orm/queries) — the query builder and scopes.
|
|
364
364
|
- [Serialization](/docs/orm/serialization) — control JSON output.
|
|
365
365
|
- [Carbon](/docs/carbon) — the date type behind `datetime` casts.
|
package/docs/orm/factories.md
CHANGED
|
@@ -37,7 +37,7 @@ This writes `database/factories/PostFactory.ts`. A factory is `Factory.define(Mo
|
|
|
37
37
|
definition)`, where the definition callback receives the built-in [`fake`](#the-fake-helper)
|
|
38
38
|
helper and returns the model's default attributes:
|
|
39
39
|
|
|
40
|
-
```typescript
|
|
40
|
+
```typescript fragment
|
|
41
41
|
// database/factories/PostFactory.ts
|
|
42
42
|
import { Factory } from "@zerotal/testing";
|
|
43
43
|
import { Post } from "../../app/models/Post.ts";
|
|
@@ -56,7 +56,7 @@ supply them at create time with [`.for()`](#relating-models) or an override.
|
|
|
56
56
|
|
|
57
57
|
## Creating records
|
|
58
58
|
|
|
59
|
-
```typescript
|
|
59
|
+
```typescript fragment
|
|
60
60
|
// in a test or seeder
|
|
61
61
|
// Persist one record → Promise<Post>
|
|
62
62
|
const post = await PostFactory.create();
|
|
@@ -93,7 +93,7 @@ wins).
|
|
|
93
93
|
`.for(parent)` injects the parent's primary key as a foreign key, derived from the
|
|
94
94
|
parent's class name (`User` → `userId`):
|
|
95
95
|
|
|
96
|
-
```typescript
|
|
96
|
+
```typescript fragment
|
|
97
97
|
// in a test or seeder
|
|
98
98
|
const user = await UserFactory.create();
|
|
99
99
|
|
|
@@ -114,7 +114,7 @@ base factory stays reusable.
|
|
|
114
114
|
Force the created instance into a model state via `forceState()` (bypassing guards
|
|
115
115
|
and transition callbacks — see [Lifecycle & Events](/docs/orm/lifecycle)):
|
|
116
116
|
|
|
117
|
-
```typescript
|
|
117
|
+
```typescript fragment
|
|
118
118
|
// in a test or seeder
|
|
119
119
|
const expired = await SubscriptionFactory.state("expired").create();
|
|
120
120
|
```
|
|
@@ -123,7 +123,7 @@ const expired = await SubscriptionFactory.state("expired").create();
|
|
|
123
123
|
|
|
124
124
|
Run logic after each instance is saved — e.g. attaching related records:
|
|
125
125
|
|
|
126
|
-
```typescript
|
|
126
|
+
```typescript fragment
|
|
127
127
|
// in a test or seeder
|
|
128
128
|
const user = await UserFactory.afterCreate(async (u) => {
|
|
129
129
|
await PostFactory.for(u).count(3).create();
|
|
@@ -138,7 +138,7 @@ needs the full lifecycle to fire:
|
|
|
138
138
|
> **Note** — Suppressing hooks keeps seeding side-effect free (no logs, emails, or
|
|
139
139
|
> queued jobs). Call `dispatchEvents()` to let observers and hooks run.
|
|
140
140
|
|
|
141
|
-
```typescript
|
|
141
|
+
```typescript fragment
|
|
142
142
|
// in a test
|
|
143
143
|
// Silent — no "user registered" side effects:
|
|
144
144
|
await UserFactory.count(20).create();
|
|
@@ -150,7 +150,7 @@ Queue.assertDispatched(WelcomeEmailJob);
|
|
|
150
150
|
|
|
151
151
|
## In seeders
|
|
152
152
|
|
|
153
|
-
```typescript
|
|
153
|
+
```typescript fragment
|
|
154
154
|
// database/seeders/DatabaseSeeder.ts
|
|
155
155
|
import { Seeder } from "@zerotal/orm";
|
|
156
156
|
import { UserFactory } from "../factories/UserFactory.ts";
|
|
@@ -172,7 +172,7 @@ See [Seeding](/docs/seeding) for running seeders.
|
|
|
172
172
|
|
|
173
173
|
Factories are the standard way to arrange database state in a test:
|
|
174
174
|
|
|
175
|
-
```typescript
|
|
175
|
+
```typescript fragment
|
|
176
176
|
// in a test
|
|
177
177
|
import { UserFactory } from "../../database/factories/UserFactory.ts";
|
|
178
178
|
|
package/docs/orm/index.md
CHANGED
|
@@ -59,7 +59,9 @@ import { DatabaseConfig } from "@zerotal/orm";
|
|
|
59
59
|
import { env } from "zerotal";
|
|
60
60
|
|
|
61
61
|
export default DatabaseConfig({
|
|
62
|
-
|
|
62
|
+
// One of 'sqlite' | 'postgres' | 'mysql', written literally: the field is that
|
|
63
|
+
// union, and `env()` returns a plain string. Scaffolding writes your choice here.
|
|
64
|
+
driver: "sqlite",
|
|
63
65
|
url: env("DATABASE_URL", "./database/db.sqlite"),
|
|
64
66
|
|
|
65
67
|
// PostgreSQL: 'postgres://user:pass@localhost:5432/mydb'
|
|
@@ -101,7 +103,7 @@ export default DatabaseConfig({
|
|
|
101
103
|
|
|
102
104
|
Every model is configured with the `@table()` decorator. It accepts a fluent chain or an options object — both styles are equivalent:
|
|
103
105
|
|
|
104
|
-
```typescript
|
|
106
|
+
```typescript fragment
|
|
105
107
|
// app/models/Post.ts
|
|
106
108
|
import { Model, column, table } from "@zerotal/orm";
|
|
107
109
|
|
|
@@ -143,7 +145,7 @@ Timestamps are **on by default**, so `@table("ledger")` alone still writes `crea
|
|
|
143
145
|
`updated_at`. For an append-only table whose migration creates neither column, say so —
|
|
144
146
|
otherwise the first save fails with `table ledger has no column named updated_at`:
|
|
145
147
|
|
|
146
|
-
```typescript
|
|
148
|
+
```typescript fragment
|
|
147
149
|
@(table("ledger").withoutTimestamps())
|
|
148
150
|
export class LedgerEntry extends Model {
|
|
149
151
|
/* … */
|
|
@@ -173,7 +175,7 @@ export class LedgerEntry extends Model {
|
|
|
173
175
|
|
|
174
176
|
Declare typed columns. Accepts a shorthand cast string or a full options object:
|
|
175
177
|
|
|
176
|
-
```typescript
|
|
178
|
+
```typescript fragment
|
|
177
179
|
// in a model class body
|
|
178
180
|
import { column } from "@zerotal/orm";
|
|
179
181
|
|
|
@@ -220,7 +222,7 @@ fails at the first `.diffForHumans()` or arithmetic.
|
|
|
220
222
|
|
|
221
223
|
Declare constraints on the column and schema generation emits them, so `migrate:generate` produces a schema with the guarantees your application depends on rather than a bare set of columns:
|
|
222
224
|
|
|
223
|
-
```typescript
|
|
225
|
+
```typescript fragment
|
|
224
226
|
@column({ unique: true }) idempotencyKey!: string; // unique index
|
|
225
227
|
@column({ index: true }) status!: string; // plain index
|
|
226
228
|
```
|
|
@@ -233,7 +235,7 @@ Reusable model behaviour ships as **mixins** — soft deletes, state machines, t
|
|
|
233
235
|
roles, permissions, notifications, tenancy, auditing. A model opts into the ones it wants with the
|
|
234
236
|
`Model.using(...)` static, so a model that does not use a feature does not carry its API:
|
|
235
237
|
|
|
236
|
-
```typescript
|
|
238
|
+
```typescript fragment
|
|
237
239
|
import { Model, SoftDeletes } from "@zerotal/orm";
|
|
238
240
|
import { Authenticatable, Roles, Permissions } from "@zerotal/auth";
|
|
239
241
|
|
|
@@ -274,7 +276,7 @@ The mixins the framework ships:
|
|
|
274
276
|
`using` composes onto whatever class you call it on, not onto `Model` specifically, so an
|
|
275
277
|
app-level base model can carry its own configuration and still take mixins:
|
|
276
278
|
|
|
277
|
-
```typescript
|
|
279
|
+
```typescript fragment
|
|
278
280
|
class AppModel extends Model {
|
|
279
281
|
static override primaryKey = "uuid";
|
|
280
282
|
}
|
|
@@ -324,7 +326,7 @@ in `ctx.body()`. To allow columns through, you opt in explicitly.
|
|
|
324
326
|
Any attribute not permitted by the active list throws `MassAssignmentError` (it is
|
|
325
327
|
**not** silently dropped), so a mistake surfaces loudly instead of quietly failing:
|
|
326
328
|
|
|
327
|
-
```typescript
|
|
329
|
+
```typescript fragment
|
|
328
330
|
// app/models/Post.ts
|
|
329
331
|
@table("posts")
|
|
330
332
|
export class Post extends Model {
|
|
@@ -342,7 +344,7 @@ export class Post extends Model {
|
|
|
342
344
|
|
|
343
345
|
Use the `Columns<T>` utility for compile-time safety against typos:
|
|
344
346
|
|
|
345
|
-
```typescript
|
|
347
|
+
```typescript fragment
|
|
346
348
|
// app/models/Post.ts
|
|
347
349
|
import type { Columns } from "@zerotal/orm";
|
|
348
350
|
|
|
@@ -353,7 +355,7 @@ static fillable: Columns<Post>[] = ["title", "body", "status"];
|
|
|
353
355
|
Declare `fillable` as a literal tuple (`as const`) and `create()` narrows its payload to
|
|
354
356
|
exactly those columns:
|
|
355
357
|
|
|
356
|
-
```typescript
|
|
358
|
+
```typescript fragment
|
|
357
359
|
@table("customers")
|
|
358
360
|
export class Customer extends Model {
|
|
359
361
|
static fillable = ["name", "email"] as const;
|
|
@@ -378,7 +380,7 @@ mistake from a runtime `MassAssignmentError` to a compile error.
|
|
|
378
380
|
For data you construct yourself (seeders, factories, framework-internal writes)
|
|
379
381
|
the guard is just friction. Bypass it deliberately:
|
|
380
382
|
|
|
381
|
-
```typescript
|
|
383
|
+
```typescript fragment
|
|
382
384
|
// Per call — skip the guard for one write:
|
|
383
385
|
role.forceFill({ name, guard });
|
|
384
386
|
await Role.forceCreate({ name, guard });
|
|
@@ -407,7 +409,7 @@ An explicit `fillable` / `guarded` list is always honoured, even under a global
|
|
|
407
409
|
|
|
408
410
|
### fill instance method
|
|
409
411
|
|
|
410
|
-
```typescript
|
|
412
|
+
```typescript fragment
|
|
411
413
|
// in a controller
|
|
412
414
|
post.fill({ title: "New title", body: "Updated body" }); // throws if a key isn't fillable
|
|
413
415
|
await post.save();
|
|
@@ -421,7 +423,7 @@ await post.save();
|
|
|
421
423
|
|
|
422
424
|
Fields listed in `hashable` are automatically hashed with `Bun.password.hash()` (bcrypt) before every `INSERT`, and on `UPDATE` only when the value has changed since the last load:
|
|
423
425
|
|
|
424
|
-
```typescript
|
|
426
|
+
```typescript fragment
|
|
425
427
|
// app/models/User.ts
|
|
426
428
|
@table("users")
|
|
427
429
|
export class User extends Model {
|
|
@@ -443,14 +445,14 @@ const ok = await Bun.password.verify(candidate, user.password);
|
|
|
443
445
|
|
|
444
446
|
`dispatchesEvents` connects ORM lifecycle hooks to the application event bus without wiring every observer manually. Declare a map from lifecycle event names to event classes:
|
|
445
447
|
|
|
446
|
-
```typescript
|
|
448
|
+
```typescript fragment
|
|
447
449
|
// app/events/UserCreated.ts
|
|
448
450
|
export class UserCreated {
|
|
449
451
|
constructor(public user: User) {}
|
|
450
452
|
}
|
|
451
453
|
```
|
|
452
454
|
|
|
453
|
-
```typescript
|
|
455
|
+
```typescript fragment
|
|
454
456
|
// app/models/User.ts
|
|
455
457
|
@table("users")
|
|
456
458
|
export class User extends Model {
|
|
@@ -466,7 +468,7 @@ export class User extends Model {
|
|
|
466
468
|
|
|
467
469
|
Each event class is constructed with the model instance as its first argument and emitted on the container's event bus (a no-op when no bus is bound, so it's safe in standalone ORM use). Subscribe anywhere:
|
|
468
470
|
|
|
469
|
-
```typescript
|
|
471
|
+
```typescript fragment
|
|
470
472
|
// app/listeners/sendWelcome.ts
|
|
471
473
|
import { Events } from "zerotal";
|
|
472
474
|
import { UserCreated } from "#app/events/UserCreated.ts";
|
|
@@ -506,7 +508,7 @@ export class Post extends Model {
|
|
|
506
508
|
|
|
507
509
|
## Full model example
|
|
508
510
|
|
|
509
|
-
```typescript
|
|
511
|
+
```typescript fragment
|
|
510
512
|
// app/models/Post.ts
|
|
511
513
|
import { Model, column, table, hasMany, belongsTo } from "@zerotal/orm";
|
|
512
514
|
import type { Columns } from "@zerotal/orm";
|
|
@@ -557,7 +559,7 @@ worth knowing: `UserFactory.create()` writes the row without firing `creating`,
|
|
|
557
559
|
jobs. A test asserting on a side-effect of creation therefore sees nothing —
|
|
558
560
|
and reads as a bug in your observer rather than in the test.
|
|
559
561
|
|
|
560
|
-
```typescript
|
|
562
|
+
```typescript fragment
|
|
561
563
|
// tests/models/User.test.ts
|
|
562
564
|
import { test } from "bun:test";
|
|
563
565
|
import { QueueFake } from "@zerotal/queue";
|
|
@@ -578,7 +580,7 @@ test("creating a user queues the welcome email", async () => {
|
|
|
578
580
|
them against the model directly — it is faster and the failure points at the
|
|
579
581
|
right line:
|
|
580
582
|
|
|
581
|
-
```typescript
|
|
583
|
+
```typescript fragment
|
|
582
584
|
// tests/models/Post.test.ts
|
|
583
585
|
import { test, expect } from "bun:test";
|
|
584
586
|
import { PostFactory } from "../../database/factories/PostFactory.ts";
|
|
@@ -598,7 +600,7 @@ test("the published scope excludes drafts", async () => {
|
|
|
598
600
|
ambiguous. Assert on both sides — gone from the normal query, present with
|
|
599
601
|
`withTrashed()`:
|
|
600
602
|
|
|
601
|
-
```typescript
|
|
603
|
+
```typescript fragment
|
|
602
604
|
// tests/models/Post.test.ts
|
|
603
605
|
await post.delete();
|
|
604
606
|
|