@edium/halifax 2.1.0 → 2.2.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 +64 -1
- package/README.md +102 -17
- package/README_AUTH.md +38 -0
- package/README_AUTOCRUD.md +5 -5
- package/README_CLASSES.md +322 -0
- package/README_HOOKS.md +275 -0
- package/README_INTERFACES.md +601 -0
- package/README_OPENAPI.md +471 -0
- package/README_REPO_ADAPTERS.md +77 -0
- package/README_TYPES.md +114 -0
- package/dist/adapters/orm/drizzle/DrizzleAdapter.d.ts +128 -0
- package/dist/adapters/orm/drizzle/DrizzleAdapter.js +255 -0
- package/dist/adapters/orm/drizzle/astToDrizzle.d.ts +21 -0
- package/dist/adapters/orm/drizzle/astToDrizzle.js +121 -0
- package/dist/adapters/orm/drizzle/index.d.ts +4 -0
- package/dist/adapters/orm/drizzle/index.js +2 -0
- package/dist/adapters/orm/prisma/PrismaAdapter.d.ts +1 -1
- package/dist/adapters/orm/prisma/PrismaAdapter.js +24 -1
- package/dist/adapters/orm/prisma/astToPrisma.d.ts +1 -2
- package/dist/adapters/orm/prisma/astToPrisma.js +1 -3
- package/dist/adapters/orm/prisma/helpers.js +1 -1
- package/dist/adapters/orm/prisma/types.d.ts +11 -11
- package/dist/auth/AuthStrategy.d.ts +6 -189
- package/dist/auth/AuthStrategy.js +4 -220
- package/dist/auth/strategies/AllowAllAuthStrategy.d.ts +6 -0
- package/dist/auth/strategies/AllowAllAuthStrategy.js +6 -0
- package/dist/auth/strategies/ApiKeyAuthStrategy.d.ts +25 -0
- package/dist/auth/strategies/ApiKeyAuthStrategy.js +39 -0
- package/dist/auth/strategies/JwtClaimsAuthStrategy.d.ts +32 -0
- package/dist/auth/strategies/JwtClaimsAuthStrategy.js +52 -0
- package/dist/auth/strategies/PassportStrategies.d.ts +94 -0
- package/dist/auth/strategies/PassportStrategies.js +142 -0
- package/dist/auth/strategies/types.d.ts +70 -0
- package/dist/core/crudRouter.d.ts +11 -18
- package/dist/core/crudRouter.js +95 -390
- package/dist/core/fields.d.ts +8 -0
- package/dist/core/fields.js +14 -0
- package/dist/core/handlerUtils.d.ts +70 -0
- package/dist/core/handlerUtils.js +193 -0
- package/dist/core/handlers/create.d.ts +3 -0
- package/dist/core/handlers/create.js +26 -0
- package/dist/core/handlers/deleteMany.d.ts +3 -0
- package/dist/core/handlers/deleteMany.js +24 -0
- package/dist/core/handlers/deleteOne.d.ts +3 -0
- package/dist/core/handlers/deleteOne.js +19 -0
- package/dist/core/handlers/query.d.ts +3 -0
- package/dist/core/handlers/query.js +23 -0
- package/dist/core/handlers/readMany.d.ts +3 -0
- package/dist/core/handlers/readMany.js +18 -0
- package/dist/core/handlers/readOne.d.ts +3 -0
- package/dist/core/handlers/readOne.js +23 -0
- package/dist/core/handlers/updateMany.d.ts +3 -0
- package/dist/core/handlers/updateMany.js +34 -0
- package/dist/core/handlers/updateOne.d.ts +3 -0
- package/dist/core/handlers/updateOne.js +20 -0
- package/dist/core/handlers/upsertOne.d.ts +3 -0
- package/dist/core/handlers/upsertOne.js +20 -0
- package/dist/core/hooks.d.ts +217 -0
- package/dist/core/queryString.js +1 -1
- package/dist/core/types.d.ts +38 -29
- package/dist/core/validation.d.ts +1 -2
- package/dist/core/validation.js +1 -3
- package/dist/index.d.ts +3 -6
- package/dist/index.js +3 -6
- package/dist/openapi/generateDocsHtml.d.ts +1 -0
- package/dist/openapi/generateDocsHtml.js +47 -0
- package/dist/openapi/index.d.ts +3 -0
- package/dist/openapi/index.js +2 -0
- package/dist/openapi/specGenerator.d.ts +149 -0
- package/dist/openapi/specGenerator.js +770 -0
- package/package.json +38 -22
- package/dist/enums/SqlComparison.d.ts +0 -28
- package/dist/enums/SqlComparison.js +0 -29
- package/dist/enums/SqlOperator.d.ts +0 -5
- package/dist/enums/SqlOperator.js +0 -6
- package/dist/enums/SqlOrder.d.ts +0 -5
- package/dist/enums/SqlOrder.js +0 -6
- package/dist/interfaces/IQueryFilter.d.ts +0 -17
- package/dist/interfaces/IQueryOptions.d.ts +0 -20
- package/dist/interfaces/ISort.d.ts +0 -8
- package/dist/interfaces/ISort.js +0 -1
- /package/dist/{interfaces/IQueryFilter.js → auth/strategies/types.js} +0 -0
- /package/dist/{interfaces/IQueryOptions.js → core/hooks.js} +0 -0
package/README_HOOKS.md
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# Halifax — Lifecycle Hooks
|
|
2
|
+
|
|
3
|
+
Hooks let you inject custom logic **before or after any CRUD operation** without writing a custom repository or HTTP middleware. They are defined per-resource under the `hooks` key on `ResourceDefinition`.
|
|
4
|
+
|
|
5
|
+
## Quick example
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import {
|
|
9
|
+
createExpressCrudRouter,
|
|
10
|
+
PrismaAdapter,
|
|
11
|
+
AuthorizationError,
|
|
12
|
+
type ResourceDefinition
|
|
13
|
+
} from '@edium/halifax'
|
|
14
|
+
|
|
15
|
+
const posts: ResourceDefinition = {
|
|
16
|
+
routePrefix: 'posts',
|
|
17
|
+
repository: new PrismaAdapter({ delegate: prisma.post }),
|
|
18
|
+
hooks: {
|
|
19
|
+
// Stamp createdBy / updatedBy from the authenticated user
|
|
20
|
+
beforeCreate: (data, { auth }) => ({ ...data, createdBy: auth.userId }),
|
|
21
|
+
beforeUpdateOne: (id, data, { auth }) => ({ ...data, updatedBy: auth.userId }),
|
|
22
|
+
|
|
23
|
+
// Emit a domain event after every new post is saved
|
|
24
|
+
afterCreate: async (result) => {
|
|
25
|
+
await events.emit('post.created', result)
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
// Block callers from reading soft-deleted posts (belt-and-suspenders on top of auth)
|
|
29
|
+
afterReadOne: (result) => {
|
|
30
|
+
if (result.deletedAt) throw new AuthorizationError('Not found.')
|
|
31
|
+
return result
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## How hooks work
|
|
40
|
+
|
|
41
|
+
### Before hooks
|
|
42
|
+
|
|
43
|
+
Called **before the database operation**. They receive the incoming data and can:
|
|
44
|
+
|
|
45
|
+
| Return | Effect |
|
|
46
|
+
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
47
|
+
| A **modified copy** of the data | The modified value is used instead of the original. |
|
|
48
|
+
| `void` / `undefined` | The original value is used unchanged. |
|
|
49
|
+
| **Throw any `Error`** | The operation is aborted and Halifax sends the appropriate HTTP error response. Use Halifax error classes (`BadRequestError`, `AuthorizationError`, `UnprocessableEntityError`, …) for precise status codes. |
|
|
50
|
+
|
|
51
|
+
### After hooks
|
|
52
|
+
|
|
53
|
+
Called **after the database operation, before the HTTP response is sent**. They receive the raw DB result (before `readRoles`/`selectable` field-filtering) and can:
|
|
54
|
+
|
|
55
|
+
| Return | Effect |
|
|
56
|
+
| --------------------------------- | -------------------------------------------------------------------------------------------- |
|
|
57
|
+
| A **modified copy** of the result | The modified value is sent to the client (field-filtering is applied afterwards). |
|
|
58
|
+
| `void` / `undefined` | The original result is used unchanged. |
|
|
59
|
+
| **Throw any `Error`** | A successful DB write is replaced with an error response (useful for post-write validation). |
|
|
60
|
+
|
|
61
|
+
### Hook context
|
|
62
|
+
|
|
63
|
+
Every hook receives a context object as its last argument:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
interface HookContext {
|
|
67
|
+
auth: AuthContext // resolved caller identity (userId, roles, permissions, claims)
|
|
68
|
+
resource: ResourceDefinition // the normalized resource being accessed
|
|
69
|
+
req: HttpRequest // raw HTTP request — headers, raw framework request, etc.
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Full hook reference
|
|
76
|
+
|
|
77
|
+
### Create — `POST /resource`
|
|
78
|
+
|
|
79
|
+
| Hook | Signature | Notes |
|
|
80
|
+
| -------------- | --------------------------------- | -------------------------------------------------------------------------------- |
|
|
81
|
+
| `beforeCreate` | `(data, ctx) => data \| void` | Fires **once per record** — both for single-object and array POST bodies. |
|
|
82
|
+
| `afterCreate` | `(result, ctx) => result \| void` | Fires **once per record**. Sees the full DB record before `readRoles` filtering. |
|
|
83
|
+
|
|
84
|
+
### Read — `GET /resource`
|
|
85
|
+
|
|
86
|
+
| Hook | Signature | Notes |
|
|
87
|
+
| ---------------- | --------------------------------------- | -------------------------------------------------------------- |
|
|
88
|
+
| `beforeReadMany` | `(options, ctx) => ListOptions \| void` | Modify pagination, inject extra `where` filters, force a sort. |
|
|
89
|
+
| `afterReadMany` | `(result, ctx) => ListResult \| void` | Transform the full `{ count, results }` envelope. |
|
|
90
|
+
|
|
91
|
+
### Read — `GET /resource/:id`
|
|
92
|
+
|
|
93
|
+
| Hook | Signature | Notes |
|
|
94
|
+
| --------------- | --------------------------------- | ------------------------------------- |
|
|
95
|
+
| `beforeReadOne` | `(id, ctx) => void` | Throw to block; cannot modify the ID. |
|
|
96
|
+
| `afterReadOne` | `(result, ctx) => result \| void` | Attach computed / virtual fields. |
|
|
97
|
+
|
|
98
|
+
### Update — `PATCH /resource/:id`
|
|
99
|
+
|
|
100
|
+
| Hook | Signature | Notes |
|
|
101
|
+
| ----------------- | --------------------------------- | ---------------------------------------------------- |
|
|
102
|
+
| `beforeUpdateOne` | `(id, data, ctx) => data \| void` | Modify the update payload (e.g. stamp audit fields). |
|
|
103
|
+
| `afterUpdateOne` | `(result, ctx) => result \| void` | Transform the updated record. |
|
|
104
|
+
|
|
105
|
+
### Bulk update — `PATCH /resource`
|
|
106
|
+
|
|
107
|
+
| Hook | Signature | Notes |
|
|
108
|
+
| ------------------ | ------------------------------------------- | ------------------------------------------------ |
|
|
109
|
+
| `beforeUpdateMany` | `(query, data, ctx) => void` | Throw to block; cannot modify the query or data. |
|
|
110
|
+
| `afterUpdateMany` | `(result, ctx) => UpdateManyResult \| void` | Transform the `{ updated, results? }` response. |
|
|
111
|
+
|
|
112
|
+
### Upsert — `PUT /resource/:id`
|
|
113
|
+
|
|
114
|
+
| Hook | Signature | Notes |
|
|
115
|
+
| ----------------- | --------------------------------- | ------------------------------- |
|
|
116
|
+
| `beforeUpsertOne` | `(id, data, ctx) => data \| void` | Modify the upsert payload. |
|
|
117
|
+
| `afterUpsertOne` | `(result, ctx) => result \| void` | Transform the resulting record. |
|
|
118
|
+
|
|
119
|
+
### Delete — `DELETE /resource/:id`
|
|
120
|
+
|
|
121
|
+
| Hook | Signature | Notes |
|
|
122
|
+
| ----------------- | ------------------- | -------------------------------------------------------------------------------- |
|
|
123
|
+
| `beforeDeleteOne` | `(id, ctx) => void` | The record still exists when this fires. Throw to abort. |
|
|
124
|
+
| `afterDeleteOne` | `(id, ctx) => void` | The record is gone. Use for cleanup or event emission. Does **not** fire on 404. |
|
|
125
|
+
|
|
126
|
+
### Bulk delete — `DELETE /resource`
|
|
127
|
+
|
|
128
|
+
| Hook | Signature | Notes |
|
|
129
|
+
| ------------------ | ------------------------------------------- | ------------------------------------- |
|
|
130
|
+
| `beforeDeleteMany` | `(query, ctx) => void` | Throw to block. |
|
|
131
|
+
| `afterDeleteMany` | `(result, ctx) => DeleteManyResult \| void` | Transform the `{ deleted }` response. |
|
|
132
|
+
|
|
133
|
+
### Query builder — `POST /resource/query`
|
|
134
|
+
|
|
135
|
+
| Hook | Signature | Notes |
|
|
136
|
+
| ------------- | --------------------------------------- | ---------------------------------------------------------------------------------------- |
|
|
137
|
+
| `beforeQuery` | `(query, ctx) => IQueryOptions \| void` | Augment or restrict the query (e.g. inject a mandatory filter the client cannot remove). |
|
|
138
|
+
| `afterQuery` | `(result, ctx) => QueryResult \| void` | Transform the `{ count, results }` response. |
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Common patterns
|
|
143
|
+
|
|
144
|
+
### Stamp audit fields
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
hooks: {
|
|
148
|
+
beforeCreate: (data, { auth }) => ({ ...data, createdBy: auth.userId }),
|
|
149
|
+
beforeUpdateOne: (id, data, { auth }) => ({ ...data, updatedBy: auth.userId }),
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Emit domain events
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
hooks: {
|
|
157
|
+
afterCreate: async (result) => events.emit('post.created', result),
|
|
158
|
+
afterUpdateOne: async (result) => events.emit('post.updated', result),
|
|
159
|
+
afterDeleteOne: async (id) => events.emit('post.deleted', { id }),
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Enforce ownership on reads
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
import { AuthorizationError } from '@edium/halifax'
|
|
167
|
+
|
|
168
|
+
hooks: {
|
|
169
|
+
beforeReadOne: async (id, { auth }) => {
|
|
170
|
+
const record = await db.post.findUnique({ where: { id: Number(id) } })
|
|
171
|
+
if (record?.ownerId !== auth.userId) throw new AuthorizationError()
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Restrict query-builder results to the caller's own data
|
|
177
|
+
|
|
178
|
+
Works as a server-side guard that clients cannot bypass, similar to tenant scoping:
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
hooks: {
|
|
182
|
+
beforeQuery: (query, { auth }) => ({
|
|
183
|
+
...query,
|
|
184
|
+
where: [
|
|
185
|
+
...(query.where ?? []),
|
|
186
|
+
{ field: 'ownerId', comparison: '=', value1: auth.userId, operator: 'AND' }
|
|
187
|
+
]
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Soft-delete interception
|
|
193
|
+
|
|
194
|
+
Block reads on soft-deleted records even after the DB returns them:
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
hooks: {
|
|
198
|
+
afterReadOne: (result) => { if (result.deletedAt) throw new NotFoundError(); return result },
|
|
199
|
+
afterReadMany: (result) => ({
|
|
200
|
+
...result,
|
|
201
|
+
results: result.results.filter((r) => !r.deletedAt),
|
|
202
|
+
count: result.results.filter((r) => !r.deletedAt).length
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Validate business rules before a write
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
import { UnprocessableEntityError } from '@edium/halifax'
|
|
211
|
+
|
|
212
|
+
hooks: {
|
|
213
|
+
beforeCreate: async (data) => {
|
|
214
|
+
const exists = await db.user.findUnique({ where: { email: data.email } })
|
|
215
|
+
if (exists) throw new UnprocessableEntityError('Email already registered.')
|
|
216
|
+
return data
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
## TypeScript generics
|
|
224
|
+
|
|
225
|
+
Hook callbacks are fully typed when you pass generics to `ResourceDefinition`:
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
interface Post {
|
|
229
|
+
id: number
|
|
230
|
+
title: string
|
|
231
|
+
body: string
|
|
232
|
+
createdBy?: string
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const posts: ResourceDefinition<Post, Omit<Post, 'id' | 'createdBy'>, Partial<Post>> = {
|
|
236
|
+
routePrefix: 'posts',
|
|
237
|
+
repository: new PrismaAdapter({ delegate: prisma.post }),
|
|
238
|
+
hooks: {
|
|
239
|
+
// `data` is typed as `Omit<Post, 'id' | 'createdBy'>` ✓
|
|
240
|
+
beforeCreate: (data, { auth }) => ({ ...data, createdBy: auth.userId }),
|
|
241
|
+
// `result` is typed as `Post` ✓
|
|
242
|
+
afterCreate: (result) => result
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
## Execution order
|
|
250
|
+
|
|
251
|
+
For a `POST /resource` call, the full pipeline is:
|
|
252
|
+
|
|
253
|
+
```
|
|
254
|
+
request
|
|
255
|
+
→ auth & authorization check
|
|
256
|
+
→ field write-filtering (writable / writeRoles)
|
|
257
|
+
→ beforeCreate (per item)
|
|
258
|
+
→ repository.createOne / .createMany
|
|
259
|
+
→ afterCreate (per item)
|
|
260
|
+
→ field read-filtering (selectable / readRoles)
|
|
261
|
+
→ response
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Hooks run **inside** the existing error-catching wrapper, so any `Error` thrown is automatically serialized as `{ errors: [{ code, message }] }` and the correct HTTP status is set.
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
## Relation to other extension points
|
|
269
|
+
|
|
270
|
+
| Extension point | When to use |
|
|
271
|
+
| ------------------------ | --------------------------------------------------------------------------------------------------------------------- |
|
|
272
|
+
| **Hooks** | Per-resource logic that runs inside the Halifax pipeline (stamping, eventing, ownership checks, data transformation). |
|
|
273
|
+
| **AuthStrategy** | Cross-resource authentication and coarse-grained authorization applied to every route. |
|
|
274
|
+
| **Repository wrapper** | Low-level data-access customization (query rewriting, custom SQL, alternative storage). |
|
|
275
|
+
| **Framework middleware** | Transport-level concerns: CORS, rate limiting, request ID injection, body parsing. |
|