@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.
Files changed (160) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +67 -0
  4. package/api-surface.md +283 -0
  5. package/docs/about.md +676 -0
  6. package/docs/admin/actions.md +183 -0
  7. package/docs/admin/auth.md +89 -0
  8. package/docs/admin/dashboard.md +155 -0
  9. package/docs/admin/extending-ui.md +229 -0
  10. package/docs/admin/extending.md +205 -0
  11. package/docs/admin/forms.md +155 -0
  12. package/docs/admin/index.md +144 -0
  13. package/docs/admin/operations.md +224 -0
  14. package/docs/admin/references.md +174 -0
  15. package/docs/admin/resources.md +132 -0
  16. package/docs/admin/structure.md +158 -0
  17. package/docs/admin/tables.md +185 -0
  18. package/docs/admin/testing.md +138 -0
  19. package/docs/ai.md +373 -0
  20. package/docs/application.md +435 -0
  21. package/docs/arch.md +263 -0
  22. package/docs/assets.md +755 -0
  23. package/docs/audit.md +367 -0
  24. package/docs/authentication.md +1000 -0
  25. package/docs/authorization.md +529 -0
  26. package/docs/broadcasting/channels.md +170 -0
  27. package/docs/broadcasting/client.md +108 -0
  28. package/docs/broadcasting/events.md +190 -0
  29. package/docs/broadcasting/index.md +163 -0
  30. package/docs/broadcasting/references.md +84 -0
  31. package/docs/broadcasting/testing.md +132 -0
  32. package/docs/cache.md +387 -0
  33. package/docs/carbon.md +830 -0
  34. package/docs/changelog.md +465 -0
  35. package/docs/client/auth.md +113 -0
  36. package/docs/client/errors.md +139 -0
  37. package/docs/client/files.md +118 -0
  38. package/docs/client/index.md +128 -0
  39. package/docs/client/references.md +58 -0
  40. package/docs/client/requests.md +131 -0
  41. package/docs/client/resilience.md +141 -0
  42. package/docs/client/testing.md +146 -0
  43. package/docs/commands.md +450 -0
  44. package/docs/components.md +2427 -0
  45. package/docs/config-system.md +258 -0
  46. package/docs/container.md +596 -0
  47. package/docs/context.md +579 -0
  48. package/docs/contributing.md +164 -0
  49. package/docs/controllers.md +354 -0
  50. package/docs/conventions.md +466 -0
  51. package/docs/cookies.md +101 -0
  52. package/docs/csrf.md +206 -0
  53. package/docs/database.md +476 -0
  54. package/docs/deployment.md +434 -0
  55. package/docs/devtools.md +1013 -0
  56. package/docs/email-verification.md +320 -0
  57. package/docs/encryption.md +380 -0
  58. package/docs/errors.md +317 -0
  59. package/docs/events.md +420 -0
  60. package/docs/flow/components.md +846 -0
  61. package/docs/flow/decorators.md +458 -0
  62. package/docs/flow/events.md +420 -0
  63. package/docs/flow/forms.md +551 -0
  64. package/docs/flow/index.md +568 -0
  65. package/docs/flow/layouts.md +793 -0
  66. package/docs/flow/lifecycle.md +336 -0
  67. package/docs/flow/pagination.md +402 -0
  68. package/docs/flow/performance.md +262 -0
  69. package/docs/flow/references.md +317 -0
  70. package/docs/flow/routing.md +550 -0
  71. package/docs/flow/testing.md +447 -0
  72. package/docs/getting-started.md +286 -0
  73. package/docs/health.md +269 -0
  74. package/docs/helpers.md +423 -0
  75. package/docs/i18n.md +414 -0
  76. package/docs/index.md +65 -0
  77. package/docs/inertia/build.md +95 -0
  78. package/docs/inertia/devtools.md +197 -0
  79. package/docs/inertia/index.md +267 -0
  80. package/docs/inertia/middleware.md +81 -0
  81. package/docs/inertia/props.md +507 -0
  82. package/docs/inertia/references.md +144 -0
  83. package/docs/inertia/rendering.md +189 -0
  84. package/docs/inertia/ssr.md +102 -0
  85. package/docs/inspirations.md +257 -0
  86. package/docs/lifecycle.md +331 -0
  87. package/docs/lock.md +454 -0
  88. package/docs/logger.md +517 -0
  89. package/docs/media.md +582 -0
  90. package/docs/middleware.md +509 -0
  91. package/docs/migrations.md +529 -0
  92. package/docs/monitor.md +377 -0
  93. package/docs/notifications.md +848 -0
  94. package/docs/orm/casts.md +365 -0
  95. package/docs/orm/factories.md +231 -0
  96. package/docs/orm/index.md +663 -0
  97. package/docs/orm/lifecycle.md +421 -0
  98. package/docs/orm/queries.md +692 -0
  99. package/docs/orm/relationships.md +440 -0
  100. package/docs/orm/serialization.md +251 -0
  101. package/docs/package-development.md +536 -0
  102. package/docs/pagination.md +340 -0
  103. package/docs/password-reset.md +335 -0
  104. package/docs/providers.md +447 -0
  105. package/docs/query-builder.md +465 -0
  106. package/docs/queue.md +549 -0
  107. package/docs/rate-limiting.md +253 -0
  108. package/docs/responses.md +336 -0
  109. package/docs/roles-and-2fa.md +418 -0
  110. package/docs/routing.md +1008 -0
  111. package/docs/scaffolding.md +335 -0
  112. package/docs/scheduler.md +533 -0
  113. package/docs/seeding.md +229 -0
  114. package/docs/session.md +451 -0
  115. package/docs/social.md +524 -0
  116. package/docs/storage.md +622 -0
  117. package/docs/structure.md +230 -0
  118. package/docs/support-policy.md +114 -0
  119. package/docs/telemetry.md +505 -0
  120. package/docs/tenancy.md +608 -0
  121. package/docs/testing/browser.md +153 -0
  122. package/docs/testing/console.md +130 -0
  123. package/docs/testing/database.md +238 -0
  124. package/docs/testing/flow-browser.md +216 -0
  125. package/docs/testing/http.md +441 -0
  126. package/docs/testing/index.md +240 -0
  127. package/docs/testing/mocking.md +410 -0
  128. package/docs/upgrade.md +192 -0
  129. package/docs/validator.md +549 -0
  130. package/docs/view.md +453 -0
  131. package/package.json +60 -0
  132. package/src/bin/mcp.ts +73 -0
  133. package/src/config.ts +63 -0
  134. package/src/errors.ts +27 -0
  135. package/src/index.ts +56 -0
  136. package/src/install/ArchInstallCommand.ts +189 -0
  137. package/src/install/detect.ts +98 -0
  138. package/src/install/guidelines.ts +290 -0
  139. package/src/install/markers.ts +77 -0
  140. package/src/install/mcpConfig.ts +81 -0
  141. package/src/mcp/index.ts +27 -0
  142. package/src/mcp/jsonrpc.ts +133 -0
  143. package/src/mcp/server.ts +307 -0
  144. package/src/mcp/stdio.ts +111 -0
  145. package/src/mcp/types.ts +146 -0
  146. package/src/probe/ArchProbeCommand.ts +48 -0
  147. package/src/probe/sentinel.ts +13 -0
  148. package/src/probe/topics.ts +291 -0
  149. package/src/provider/ArchProvider.ts +101 -0
  150. package/src/tools/_probe.ts +164 -0
  151. package/src/tools/apiSurface.ts +241 -0
  152. package/src/tools/appInfo.ts +88 -0
  153. package/src/tools/baselines.ts +210 -0
  154. package/src/tools/context.ts +37 -0
  155. package/src/tools/doctor.ts +93 -0
  156. package/src/tools/index.ts +50 -0
  157. package/src/tools/logs.ts +265 -0
  158. package/src/tools/routes.ts +133 -0
  159. package/src/tools/schema.ts +119 -0
  160. 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.