|
1 | | -# Logic: Hooks (Triggers) |
| 1 | +# Logic Hooks |
2 | 2 |
|
3 | | -Hooks allow you to inject business logic into the database lifecycle. They are powerful, typed, and context-aware. |
| 3 | +Hooks (often called "Triggers" in SQL databases) allow you to intercept database operations to inject custom logic. They are transaction-aware and fully typed. |
4 | 4 |
|
5 | | -## 1. File Structure |
6 | | -Hooks live in `*.hook.ts` files alongside your object definitions. The loader automatically binds them based on the filename. |
| 5 | +## 1. Registration Methods |
7 | 6 |
|
8 | | -* `objects/todo.object.yml` |
9 | | -* `objects/todo.hook.ts` |
| 7 | +You can define hooks in two ways: **File-based** (Static) or **Programmatic** (Dynamic). |
10 | 8 |
|
11 | | -## 2. Defining Hooks |
| 9 | +### A. File-based (Recommended) |
| 10 | +Place a `*.hook.ts` file next to your object definition. The loader automatically discovers it. |
12 | 11 |
|
13 | | -Export a default object that satisfies the `ObjectHookDefinition<T>` interface. |
| 12 | +**File:** `src/objects/project.hook.ts` |
14 | 13 |
|
15 | 14 | ```typescript |
16 | | -// objects/todo.hook.ts |
17 | 15 | import { ObjectHookDefinition } from '@objectql/types'; |
18 | | -import { Todo } from './types'; // Generated types |
19 | | - |
20 | | -const hooks: ObjectHookDefinition<Todo> = { |
21 | | - |
22 | | - // Validate data before insertion |
23 | | - beforeCreate: async ({ data, user }) => { |
24 | | - if (!data.title) { |
25 | | - throw new Error("Title is required"); |
26 | | - } |
27 | | - data.owner_id = user?.id; |
28 | | - }, |
29 | 16 |
|
30 | | - // Check state changes before update |
31 | | - // 'previousData' is automatically fetched for you! |
32 | | - beforeUpdate: async ({ id, data, previousData, isModified }) => { |
33 | | - if (isModified('status')) { |
34 | | - if (previousData.status === 'Archived') { |
35 | | - throw new Error("Cannot modify archived todos"); |
36 | | - } |
37 | | - } |
| 17 | +const hooks: ObjectHookDefinition = { |
| 18 | + beforeCreate: async (ctx) => { |
| 19 | + // ... |
38 | 20 | }, |
39 | | - |
40 | | - // Side-effects after successful commitment |
41 | | - afterCreate: async ({ result, api }) => { |
42 | | - await api.create('log', { |
43 | | - message: `New Todo Created: ${result.title}` |
44 | | - }); |
| 21 | + afterUpdate: async (ctx) => { |
| 22 | + // ... |
45 | 23 | } |
46 | | -} |
| 24 | +}; |
47 | 25 |
|
48 | 26 | export default hooks; |
49 | 27 | ``` |
50 | 28 |
|
| 29 | +### B. Programmatic (Dynamic) |
| 30 | +Use the `app.on()` API, typically inside a [Plugin](./plugins.md). |
| 31 | + |
| 32 | +```typescript |
| 33 | +app.on('before:create', 'project', async (ctx) => { |
| 34 | + // ... |
| 35 | +}); |
| 36 | + |
| 37 | +// Wildcard listener |
| 38 | +app.on('after:delete', '*', async (ctx) => { |
| 39 | + console.log(`Object ${ctx.objectName} deleted record ${ctx.id}`); |
| 40 | +}); |
| 41 | +``` |
| 42 | + |
| 43 | +## 2. Event Lifecycle |
| 44 | + |
| 45 | +| Event Name | Description | Common Use Case | |
| 46 | +| :--- | :--- | :--- | |
| 47 | +| `before:create` | Before inserting a new record. | Validation, Default Values, ID generation. | |
| 48 | +| `after:create` | After insertion is committed. | Notifications, downstream sync. | |
| 49 | +| `before:update` | Before modifying an existing record. | Permission checks, Immutable field protection. | |
| 50 | +| `after:update` | After modification is committed. | Audit logging, history tracking. | |
| 51 | +| `before:delete` | Before removing a record. | Referential integrity checks. | |
| 52 | +| `after:delete` | After removal is committed. | Clean up related resources (e.g. S3 files). | |
| 53 | +| `before:find` | Before executing a query. | **Row-Level Security (RLS)**, Force filters. | |
| 54 | +| `after:find` | After fetching results. | Decryption, Sensitive data masking. | |
| 55 | + |
51 | 56 | ## 3. The Hook Context |
52 | 57 |
|
53 | | -The context passed to your function is **Operation-Specific**. |
| 58 | +The context object (`ctx`) changes based on the event type. |
| 59 | + |
| 60 | +### Common Properties (Available Everywhere) |
| 61 | + |
| 62 | +| Property | Type | Description | |
| 63 | +| :--- | :--- | :--- | |
| 64 | +| `objectName` | `string` | The name of the object being operated on. | |
| 65 | +| `user` | `ObjectQLUser` | Current user session/context. | |
| 66 | +| `broker` | `IStation` | (If Microservices enabled) Station broker instance. | |
| 67 | + |
| 68 | +### Mutation Context (Create/Update/Delete) |
| 69 | + |
| 70 | +| Property | Type | Available In | Description | |
| 71 | +| :--- | :--- | :--- | :--- | |
| 72 | +| `data` | `Any` | Create/Update | The data payload being written. **Mutable**. | |
| 73 | +| `id` | `string` | Update/Delete | The ID of the record being acted upon. | |
| 74 | +| `previousData` | `Any` | Update/Delete | The existing record fetched from DB before operation. | |
| 75 | +| `result` | `Any` | After * | The final result returned from the driver. | |
54 | 76 |
|
55 | | -| Property | Available In | Description | |
| 77 | +### Query Context (Find) |
| 78 | + |
| 79 | +| Property | Type | Description | |
56 | 80 | | :--- | :--- | :--- | |
57 | | -| `api` | All | Restricted driver API to perform DB operations (`find`, `create`, etc). | |
58 | | -| `user` | All | The current user session. | |
59 | | -| `state` | All | Shared storage to pass data from `before` -> `after` hooks. | |
60 | | -| `data` | Create/Update | The payload being written. | |
61 | | -| `previousData` | Update | The record as it exists in DB *before* this update. | |
62 | | -| `isModified(field)`| Update | Helper to check if a field is changing. | |
63 | | -| `query` | Find/Count | The query AST. Useful for row-level security. | |
| 81 | +| `query` | `steedos-filters` | The query AST (filters, fields, sort). **Mutable**. | |
| 82 | +| `result` | `Any[]` | (After Find) The array of records found. **Mutable**. | |
| 83 | + |
| 84 | +## 4. Common Patterns & Examples |
64 | 85 |
|
65 | | -### Row-Level Security Example |
| 86 | +### A. Validation & Default Values |
| 87 | +Throwing an error inside a `before` hook aborts the transaction. |
| 88 | + |
| 89 | +```typescript |
| 90 | +beforeCreate: async ({ data, user }) => { |
| 91 | + if (data.amount < 0) { |
| 92 | + throw new Error("Amount cannot be negative"); |
| 93 | + } |
| 94 | + // Set default owner if not provided |
| 95 | + if (!data.owner) { |
| 96 | + data.owner = user.userId; |
| 97 | + } |
| 98 | +} |
| 99 | +``` |
| 100 | + |
| 101 | +### B. Immutable Fields Protection |
| 102 | +Prevent users from changing critical fields during update. |
| 103 | + |
| 104 | +```typescript |
| 105 | +beforeUpdate: async ({ data, previousData }) => { |
| 106 | + if (data.code !== undefined && data.code !== previousData.code) { |
| 107 | + throw new Error("Cannot change project code once created."); |
| 108 | + } |
| 109 | +} |
| 110 | +``` |
| 111 | + |
| 112 | +### C. Row-Level Security (RLS) |
| 113 | +The most secure place to enforce permissions is `before:find`. This injects filters into *every* query (API, GraphQL, or internal). |
66 | 114 |
|
67 | 115 | ```typescript |
68 | 116 | beforeFind: async ({ query, user }) => { |
69 | | - // Forcefully filter all queries to only show user's own data |
70 | | - if (!user.isAdmin) { |
71 | | - query.filters.push(['owner_id', '=', user.id]); |
| 117 | + if (!user.is_admin) { |
| 118 | + // Enforce: owners can only see their own records |
| 119 | + // Merging into existing filters |
| 120 | + query.filters = [ |
| 121 | + (query.filters || []), |
| 122 | + ['owner', '=', user.userId] |
| 123 | + ]; |
72 | 124 | } |
73 | 125 | } |
74 | 126 | ``` |
| 127 | + |
| 128 | +### D. Side Effects (Notifications) |
| 129 | +Use `after` hooks for logic that strictly relies on success. |
| 130 | + |
| 131 | +```typescript |
| 132 | +afterCreate: async ({ data, objectName }) => { |
| 133 | + await NotificationService.send({ |
| 134 | + to: data.owner, |
| 135 | + message: `New ${objectName} created.` |
| 136 | + }); |
| 137 | +} |
| 138 | +``` |
| 139 | + |
| 140 | +### E. Result Masking |
| 141 | +Hide sensitive fields based on rules. |
| 142 | + |
| 143 | +```typescript |
| 144 | +afterFind: async ({ result, user }) => { |
| 145 | + if (!user.has_permission('view_salary')) { |
| 146 | + result.forEach(record => { |
| 147 | + delete record.salary; |
| 148 | + delete record.bonus; |
| 149 | + }); |
| 150 | + } |
| 151 | +} |
| 152 | +``` |
| 153 | + |
| 154 | +### F. Auto-Numbering / ID Generation |
| 155 | +Generate complex business keys. |
| 156 | + |
| 157 | +```typescript |
| 158 | +beforeCreate: async ({ data }) => { |
| 159 | + if (!data.code) { |
| 160 | + data.code = await SequenceService.next('PROJECT_CODE'); |
| 161 | + } |
| 162 | +} |
| 163 | +``` |
| 164 | + |
| 165 | +### G. Conditional Deletion |
| 166 | +Use `previousData` in delete hooks to prevent deleting records based on their state. |
| 167 | + |
| 168 | +```typescript |
| 169 | +beforeDelete: async ({ previousData, user }) => { |
| 170 | + // Prevent deletion if project is active |
| 171 | + if (previousData.status === 'active') { |
| 172 | + throw new Error("Cannot delete an active project. Archive it first."); |
| 173 | + } |
| 174 | +} |
| 175 | +``` |
| 176 | + |
| 177 | +## 5. Transaction Safety |
| 178 | + |
| 179 | +Hooks participate in the database transaction. |
| 180 | +* If a `before` hook throws -> The DB operation is never executed. |
| 181 | +* If the DB operation fails -> `after` hooks are never executed. |
| 182 | +* If an `after` hook throws -> **The entire transaction rolls back** (including the DB write). |
| 183 | + |
| 184 | +> **Tip:** If you want a "Fire and Forget" action that shouldn't rollback the transaction (e.g. sending an email), wrap your logic in a `try/catch` or execute it without `await`. |
0 commit comments