@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,421 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Lifecycle & Events
|
|
3
|
+
description: Hook into every persistence event, enforce status transitions, and prune stale records as your models are created, changed, and deleted.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Lifecycle & Events
|
|
7
|
+
|
|
8
|
+
Every time a model is created, updated, deleted, or hydrated, the ORM fires a
|
|
9
|
+
lifecycle event. This page covers the four ways to react to those events — raw
|
|
10
|
+
hooks, observer classes, a declarative state machine, and the app-event-bus
|
|
11
|
+
bridge — plus a pruning API for automated housekeeping.
|
|
12
|
+
|
|
13
|
+
## Mental model
|
|
14
|
+
|
|
15
|
+
A single persistence call fans out to a fixed sequence of lifecycle events. The
|
|
16
|
+
same events feed every reaction system, so you choose by how you want to organise
|
|
17
|
+
your code, not by which event you can see:
|
|
18
|
+
|
|
19
|
+
```text
|
|
20
|
+
create() → beforeSave → beforeCreate → INSERT → afterCreate → afterSave
|
|
21
|
+
update() → beforeSave → beforeUpdate → UPDATE → afterUpdate → afterSave
|
|
22
|
+
delete() → beforeDelete → DELETE → afterDelete
|
|
23
|
+
query → hydrate row → afterFind
|
|
24
|
+
│
|
|
25
|
+
├─ HookRegistry callbacks (lowest level)
|
|
26
|
+
├─ Observer methods (grouped per model)
|
|
27
|
+
└─ dispatchesEvents (onto the app event bus)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
> **Note** — Hooks and observers for the same model **both** fire; observers are
|
|
31
|
+
> registered through `HookRegistry` under the hood, so they run in registration
|
|
32
|
+
> order alongside any standalone hooks.
|
|
33
|
+
|
|
34
|
+
## Which should I use?
|
|
35
|
+
|
|
36
|
+
- **Hooks** (`HookRegistry.register`) — a one-off side-effect for a single event.
|
|
37
|
+
The lowest-level option; reach for it when you don't want a whole class.
|
|
38
|
+
- **Observers** (`Model.observe`) — several related side-effects for one model,
|
|
39
|
+
kept together in one class. Prefer this once you have more than one or two hooks.
|
|
40
|
+
- **`dispatchesEvents`** — when the consumers live in other modules and shouldn't
|
|
41
|
+
import the model. Maps lifecycle events onto the application event bus.
|
|
42
|
+
- **State machine** — when a column moves through a fixed set of statuses and you
|
|
43
|
+
want illegal transitions rejected rather than silently saved.
|
|
44
|
+
|
|
45
|
+
## Hooks
|
|
46
|
+
|
|
47
|
+
Run async callbacks before or after any persistence event. Register them once at
|
|
48
|
+
boot (typically in a service provider's `onBooting()`):
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
// in a ServiceProvider.onBooting()
|
|
52
|
+
import { HookRegistry } from "@zerotal/orm";
|
|
53
|
+
|
|
54
|
+
// Auto-generate a slug before a Post is created
|
|
55
|
+
HookRegistry.register(Post, "beforeCreate", async (post) => {
|
|
56
|
+
if (!post.slug) {
|
|
57
|
+
post.slug = slugify(post.title);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Bust the cache after a Post is deleted
|
|
62
|
+
HookRegistry.register(Post, "afterDelete", async (post) => {
|
|
63
|
+
await Cache.forget(`post:${post.id}`);
|
|
64
|
+
await Cache.forget(`post:slug:${post.slug}`);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Validate custom business rules before an update
|
|
68
|
+
HookRegistry.register(Order, "beforeUpdate", async (order) => {
|
|
69
|
+
if (order.isDirty("status") && order.status === "cancelled") {
|
|
70
|
+
if (order.original("status") === "shipped") {
|
|
71
|
+
throw new Error("Cannot cancel a shipped order.");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The signature is:
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
HookRegistry.register<T>(ModelClass: Function, hook: HookName, fn: (model: T) => Promise<void> | void): void
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**Available hooks:**
|
|
84
|
+
|
|
85
|
+
| Hook | Fires |
|
|
86
|
+
| -------------- | ----------------------------------------------- |
|
|
87
|
+
| `beforeCreate` | Before the first `INSERT` for a new model |
|
|
88
|
+
| `afterCreate` | After the `INSERT` succeeds |
|
|
89
|
+
| `beforeUpdate` | Before `UPDATE` on an existing model |
|
|
90
|
+
| `afterUpdate` | After `UPDATE` succeeds |
|
|
91
|
+
| `beforeSave` | Before both `INSERT` and `UPDATE` |
|
|
92
|
+
| `afterSave` | After both `INSERT` and `UPDATE` succeed |
|
|
93
|
+
| `beforeDelete` | Before `DELETE` (soft or hard) |
|
|
94
|
+
| `afterDelete` | After `DELETE` succeeds |
|
|
95
|
+
| `afterFind` | After any model is hydrated from a query result |
|
|
96
|
+
|
|
97
|
+
Hooks fire in registration order, and inherited hooks (registered against a parent
|
|
98
|
+
class) run before a subclass's own. Multiple hooks can be registered for the same
|
|
99
|
+
event.
|
|
100
|
+
|
|
101
|
+
> **Tip** — Factories silence hooks and observers during seeding. Call
|
|
102
|
+
> `.dispatchEvents()` on the factory to opt back in.
|
|
103
|
+
|
|
104
|
+
## Observers
|
|
105
|
+
|
|
106
|
+
Observers group all lifecycle callbacks for a model in a single class. The method
|
|
107
|
+
names follow the event-tense convention (`creating`/`created`, `updating`/
|
|
108
|
+
`updated`, `saving`/`saved`, `deleting`/`deleted`, `retrieved`) — implement only
|
|
109
|
+
the ones you need:
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
// app/observers/UserObserver.ts
|
|
113
|
+
import type { ModelObserver } from "@zerotal/orm";
|
|
114
|
+
|
|
115
|
+
export class UserObserver implements ModelObserver<User> {
|
|
116
|
+
async creating(user: User) {
|
|
117
|
+
user.slug = slugify(user.name);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async created(user: User) {
|
|
121
|
+
await Mail.send(new WelcomeMail(user.email));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async updated(user: User) {
|
|
125
|
+
if (user.isDirty("email")) {
|
|
126
|
+
await Mail.send(new EmailChangedMail(user.email));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async deleted(user: User) {
|
|
131
|
+
await Cache.forget(`user:${user.id}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Register the observer once at boot:
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
// in a ServiceProvider.onBooting()
|
|
140
|
+
import { User } from "#app/models/User.ts";
|
|
141
|
+
import { UserObserver } from "#app/observers/UserObserver.ts";
|
|
142
|
+
|
|
143
|
+
User.observe(UserObserver);
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Each observer method maps onto the matching hook — `creating` → `beforeCreate`,
|
|
147
|
+
`created` → `afterCreate`, `retrieved` → `afterFind`, and so on — so observers and
|
|
148
|
+
standalone hooks share the same registration-order execution.
|
|
149
|
+
|
|
150
|
+
## State machine
|
|
151
|
+
|
|
152
|
+
When a column moves through a fixed set of statuses, declare the allowed
|
|
153
|
+
transitions as a state machine. The behaviour is an **opt-in mixin**: compose it
|
|
154
|
+
with `Model.using(State)` so only models that declare a workflow carry the
|
|
155
|
+
`transitionTo` / `forceState` / `onTransition` API.
|
|
156
|
+
|
|
157
|
+
### Declaring states
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
// app/models/Post.ts
|
|
161
|
+
import { Model, State, column, table } from "@zerotal/orm";
|
|
162
|
+
|
|
163
|
+
const States = {
|
|
164
|
+
draft: { canTransitionTo: ["review", "archived"] as const },
|
|
165
|
+
review: { canTransitionTo: ["published", "draft"] as const },
|
|
166
|
+
published: { canTransitionTo: ["archived"] as const },
|
|
167
|
+
archived: { canTransitionTo: [] as const },
|
|
168
|
+
} as const;
|
|
169
|
+
|
|
170
|
+
@(table("posts").withTimestamps())
|
|
171
|
+
export class Post extends Model.using(State) {
|
|
172
|
+
static states = States;
|
|
173
|
+
static stateField = "status"; // default — override if your column isn't named 'status'
|
|
174
|
+
|
|
175
|
+
@column("string") status!: keyof typeof States;
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
> **Warning** — The state machine is _not_ on `Model`. A model that extends
|
|
180
|
+
> `Model` directly has no `transitionTo()`; you must compose
|
|
181
|
+
> `Model.using(State)`.
|
|
182
|
+
|
|
183
|
+
### Transition guards
|
|
184
|
+
|
|
185
|
+
A guard runs before the target state is entered. Return `false` to block the
|
|
186
|
+
transition silently, or throw a `StateError` to block it with a message:
|
|
187
|
+
|
|
188
|
+
```typescript
|
|
189
|
+
// app/models/Subscription.ts
|
|
190
|
+
import { Model, State, StateError, column, table } from "@zerotal/orm";
|
|
191
|
+
|
|
192
|
+
const States = {
|
|
193
|
+
pending: { canTransitionTo: ["active", "cancelled"] as const },
|
|
194
|
+
active: {
|
|
195
|
+
canTransitionTo: ["suspended", "expired"] as const,
|
|
196
|
+
guard: async (subscription: Subscription) => {
|
|
197
|
+
if (!subscription.stripeId) {
|
|
198
|
+
// StateError(model, from, to, detail?)
|
|
199
|
+
throw new StateError(
|
|
200
|
+
"Subscription",
|
|
201
|
+
"pending",
|
|
202
|
+
"active",
|
|
203
|
+
"Cannot activate without a Stripe ID.",
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
suspended: { canTransitionTo: ["active", "cancelled"] as const },
|
|
209
|
+
expired: { canTransitionTo: [] as const },
|
|
210
|
+
cancelled: { canTransitionTo: [] as const },
|
|
211
|
+
} as const;
|
|
212
|
+
|
|
213
|
+
@table("subscriptions")
|
|
214
|
+
export class Subscription extends Model.using(State) {
|
|
215
|
+
static states = States;
|
|
216
|
+
static stateField = "status";
|
|
217
|
+
|
|
218
|
+
@column("string") status!: keyof typeof States;
|
|
219
|
+
@column("string?") stripeId?: string;
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
> **Note** — `StateError` carries `(model, from, to, detail?)` and surfaces as an
|
|
224
|
+
> HTTP `422`. Returning `false` from a guard raises the same error with a generic
|
|
225
|
+
> message.
|
|
226
|
+
|
|
227
|
+
### Transition listeners
|
|
228
|
+
|
|
229
|
+
Register callbacks that fire after a successful transition. Call `onTransition` in
|
|
230
|
+
a service provider's `onBooting()` so it runs once at startup:
|
|
231
|
+
|
|
232
|
+
```typescript
|
|
233
|
+
// in a ServiceProvider.onBooting()
|
|
234
|
+
// Specific state
|
|
235
|
+
Post.onTransition("published", async (post, { from }) => {
|
|
236
|
+
await Mail.send(new PostPublishedMail(post));
|
|
237
|
+
console.log(`Post ${post.id} moved from ${from} → published`);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// Wildcard — fires on every transition
|
|
241
|
+
Post.onTransition("*", async (post, { from, to }) => {
|
|
242
|
+
await Audit.log("post.state_change", { id: post.id, from, to });
|
|
243
|
+
});
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
### Using transitions
|
|
247
|
+
|
|
248
|
+
```typescript
|
|
249
|
+
// in a controller or service
|
|
250
|
+
// Validates canTransitionTo, runs the guard, saves, then fires callbacks
|
|
251
|
+
await post.transitionTo("review");
|
|
252
|
+
await post.transitionTo("published");
|
|
253
|
+
|
|
254
|
+
// Throws StateError — "published" cannot transition to "draft"
|
|
255
|
+
await post.transitionTo("draft");
|
|
256
|
+
|
|
257
|
+
// Force a state without validation — blocked in production
|
|
258
|
+
await post.forceState("draft");
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
`transitionTo()` updates the state column and calls `save()` once the transition is
|
|
262
|
+
validated and the guard passes, then runs the matching `onTransition` callbacks
|
|
263
|
+
followed by any `"*"` wildcard callbacks.
|
|
264
|
+
|
|
265
|
+
> **Danger** — `forceState()` bypasses `canTransitionTo`, guards, and callbacks. It
|
|
266
|
+
> throws when `APP_ENV` is `production`, so keep it to test factories, seeders, and
|
|
267
|
+
> data-repair scripts.
|
|
268
|
+
|
|
269
|
+
### State machine in a controller
|
|
270
|
+
|
|
271
|
+
```typescript
|
|
272
|
+
// app/controllers/PostController.ts
|
|
273
|
+
import { StateError } from "@zerotal/orm";
|
|
274
|
+
|
|
275
|
+
export async function publish(ctx: HttpContext) {
|
|
276
|
+
const post = await Post.findOrFail(ctx.params("id"));
|
|
277
|
+
|
|
278
|
+
try {
|
|
279
|
+
await post.transitionTo("published");
|
|
280
|
+
return json({ status: post.status });
|
|
281
|
+
} catch (e) {
|
|
282
|
+
if (e instanceof StateError) {
|
|
283
|
+
return json({ error: e.message }, 422);
|
|
284
|
+
}
|
|
285
|
+
throw e;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
## Dispatching model events to the app bus
|
|
291
|
+
|
|
292
|
+
`dispatchesEvents` maps lifecycle events to event classes that are automatically
|
|
293
|
+
dispatched on the application event bus when they fire. This is an alternative to
|
|
294
|
+
observers — useful when consumers live in separate modules. See the
|
|
295
|
+
[ORM index page](/docs/orm#bridging-model-events-to-the-app-event-bus) for the
|
|
296
|
+
companion overview.
|
|
297
|
+
|
|
298
|
+
```typescript
|
|
299
|
+
// app/events/PostEvents.ts
|
|
300
|
+
export class PostCreated {
|
|
301
|
+
constructor(public post: Post) {}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// app/models/Post.ts
|
|
305
|
+
@table("posts")
|
|
306
|
+
export class Post extends Model {
|
|
307
|
+
static dispatchesEvents = {
|
|
308
|
+
created: PostCreated,
|
|
309
|
+
updated: PostUpdated,
|
|
310
|
+
deleted: PostDeleted,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Valid keys: `creating`, `created`, `updating`, `updated`, `saving`, `saved`,
|
|
316
|
+
`deleting`, `deleted`, `retrieved`. Each event class is constructed with the model
|
|
317
|
+
instance. Dispatching is a no-op when no event bus is bound (for example, when the
|
|
318
|
+
ORM runs standalone).
|
|
319
|
+
|
|
320
|
+
Subscribe using the event bus anywhere in your application:
|
|
321
|
+
|
|
322
|
+
```typescript
|
|
323
|
+
// in a ServiceProvider or listener module
|
|
324
|
+
import { Events } from "zerotal";
|
|
325
|
+
|
|
326
|
+
Events.on(PostCreated, async ({ post }) => {
|
|
327
|
+
await SearchIndex.sync(post);
|
|
328
|
+
await Cache.tags("posts").flush();
|
|
329
|
+
});
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
## Model pruning
|
|
333
|
+
|
|
334
|
+
Mark stale records for automatic periodic cleanup without manually writing DELETE
|
|
335
|
+
queries.
|
|
336
|
+
|
|
337
|
+
### Implementing pruning
|
|
338
|
+
|
|
339
|
+
```typescript
|
|
340
|
+
// app/models/AuditLog.ts
|
|
341
|
+
@table("audit_logs")
|
|
342
|
+
export class AuditLog extends Model {
|
|
343
|
+
@column("datetime") createdAt!: Carbon;
|
|
344
|
+
|
|
345
|
+
// Return a query that selects records eligible for deletion
|
|
346
|
+
static prunable() {
|
|
347
|
+
const cutoff = new Date();
|
|
348
|
+
cutoff.setMonth(cutoff.getMonth() - 6); // older than 6 months
|
|
349
|
+
return this.query().where("created_at", "<", cutoff.toISOString());
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Permanently remove rows instead of soft-deleting
|
|
353
|
+
static massPrune = true;
|
|
354
|
+
}
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
```typescript
|
|
358
|
+
// in a scheduled task or REPL
|
|
359
|
+
const removed = await AuditLog.prune(); // default chunk size: 1000
|
|
360
|
+
const fewer = await AuditLog.prune(500); // custom chunk size
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
The signature is:
|
|
364
|
+
|
|
365
|
+
```typescript
|
|
366
|
+
static prune(chunkSize = 1000): Promise<number>
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
`prune()` works in chunks to avoid memory spikes on large tables and returns the
|
|
370
|
+
total number of records deleted. Calling it on a model without a `prunable()`
|
|
371
|
+
method throws.
|
|
372
|
+
|
|
373
|
+
### Scheduling pruning
|
|
374
|
+
|
|
375
|
+
```typescript
|
|
376
|
+
// bootstrap/app.ts or a scheduler provider
|
|
377
|
+
scheduler
|
|
378
|
+
.job("prune-audit-logs", async () => {
|
|
379
|
+
const count = await AuditLog.prune();
|
|
380
|
+
console.log(`Pruned ${count} audit logs`);
|
|
381
|
+
})
|
|
382
|
+
.daily()
|
|
383
|
+
.at("03:00");
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
### massPrune vs soft-delete pruning
|
|
387
|
+
|
|
388
|
+
| `massPrune` | Behaviour |
|
|
389
|
+
| ----------------- | ----------------------------------------------------- |
|
|
390
|
+
| `false` (default) | `delete()` — sets `deleted_at`, respects soft deletes |
|
|
391
|
+
| `true` | `forceDelete()` — permanently removes the row |
|
|
392
|
+
|
|
393
|
+
`massPrune` only changes behaviour for models that use soft deletes (`static
|
|
394
|
+
softDeletes = true`). For hard-delete models, `delete()` is already permanent, so
|
|
395
|
+
the two are identical.
|
|
396
|
+
|
|
397
|
+
## References
|
|
398
|
+
|
|
399
|
+
| Member | Signature | Description |
|
|
400
|
+
| ----------------------- | ------------------------------------------------------------ | ----------------------------------------------------------- |
|
|
401
|
+
| `HookRegistry.register` | `(ModelClass, hook: HookName, fn): void` | Register a lifecycle callback for a model. |
|
|
402
|
+
| `Model.observe` | `(ObserverClass: new () => ModelObserver<T>): void` | Wire an observer class's methods into the hook registry. |
|
|
403
|
+
| `Model.onTransition` | `(toState: string, cb: (model, { from, to }) => void): void` | Run a callback after a successful transition (`"*"` = any). |
|
|
404
|
+
| `model.transitionTo` | `(newState: string): Promise<this>` | Validate, guard, save, then fire transition callbacks. |
|
|
405
|
+
| `model.forceState` | `(state: string): Promise<this>` | Set the state column unchecked; throws in production. |
|
|
406
|
+
| `Model.prune` | `(chunkSize = 1000): Promise<number>` | Delete `prunable()` records in chunks; returns the count. |
|
|
407
|
+
| `Model.prunable` | `(): ModelQueryBuilder<T>` | Override to return the query selecting prunable records. |
|
|
408
|
+
|
|
409
|
+
| Static field | Type | Description |
|
|
410
|
+
| ------------------------- | --------------------------------------- | ---------------------------------------------------------- |
|
|
411
|
+
| `static states` | `Record<string, StateDefinition>` | The state machine schema (requires `Model.using(State)`). |
|
|
412
|
+
| `static stateField` | `string` (default `"status"`) | Column holding the state value. |
|
|
413
|
+
| `static dispatchesEvents` | `Record<string, new (model) => object>` | Maps lifecycle keys to app-bus event classes. |
|
|
414
|
+
| `static massPrune` | `boolean` (default `false`) | Permanently delete prunable rows instead of soft-deleting. |
|
|
415
|
+
|
|
416
|
+
## Next steps
|
|
417
|
+
|
|
418
|
+
- [ORM](/docs/orm) — model basics and the `dispatchesEvents` bridge.
|
|
419
|
+
- [Events](/docs/events) — subscribe to dispatched model events on the app bus.
|
|
420
|
+
- [Scheduler](/docs/scheduler) — run pruning jobs on a recurring schedule.
|
|
421
|
+
- [ORM queries](/docs/orm/queries) — build the queries that hooks and pruning rely on.
|