@venizia/ignis 0.0.7-8 → 0.0.7-9
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/README.md +2730 -25
- package/dist/base/controllers/factory/controller.d.ts +9 -9
- package/dist/base/controllers/factory/definition.d.ts +9 -9
- package/dist/components/auth/authenticate/common/types.d.ts +2 -8
- package/dist/components/auth/authenticate/common/types.d.ts.map +1 -1
- package/dist/components/auth/authenticate/component.d.ts +1 -15
- package/dist/components/auth/authenticate/component.d.ts.map +1 -1
- package/dist/components/auth/authenticate/component.js +25 -49
- package/dist/components/auth/authenticate/component.js.map +1 -1
- package/dist/components/auth/authenticate/controllers/factory.d.ts.map +1 -1
- package/dist/components/auth/authenticate/controllers/factory.js +2 -2
- package/dist/components/auth/authenticate/controllers/factory.js.map +1 -1
- package/dist/components/auth/authenticate/strategies/basic.strategy.d.ts.map +1 -1
- package/dist/components/auth/authenticate/strategies/basic.strategy.js +8 -1
- package/dist/components/auth/authenticate/strategies/basic.strategy.js.map +1 -1
- package/dist/components/auth/authenticate/strategies/jwt.strategy.d.ts.map +1 -1
- package/dist/components/auth/authenticate/strategies/jwt.strategy.js +8 -1
- package/dist/components/auth/authenticate/strategies/jwt.strategy.js.map +1 -1
- package/dist/components/auth/authorize/adapters/base-filtered.d.ts +73 -0
- package/dist/components/auth/authorize/adapters/base-filtered.d.ts.map +1 -0
- package/dist/components/auth/authorize/adapters/base-filtered.js +98 -0
- package/dist/components/auth/authorize/adapters/base-filtered.js.map +1 -0
- package/dist/components/auth/authorize/adapters/drizzle-casbin.d.ts +39 -0
- package/dist/components/auth/authorize/adapters/drizzle-casbin.d.ts.map +1 -0
- package/dist/components/auth/authorize/adapters/drizzle-casbin.js +100 -0
- package/dist/components/auth/authorize/adapters/drizzle-casbin.js.map +1 -0
- package/dist/components/auth/authorize/adapters/index.d.ts +3 -0
- package/dist/components/auth/authorize/adapters/index.d.ts.map +1 -0
- package/dist/components/auth/authorize/adapters/index.js +19 -0
- package/dist/components/auth/authorize/adapters/index.js.map +1 -0
- package/dist/components/auth/authorize/common/constants.d.ts +37 -4
- package/dist/components/auth/authorize/common/constants.d.ts.map +1 -1
- package/dist/components/auth/authorize/common/constants.js +65 -5
- package/dist/components/auth/authorize/common/constants.js.map +1 -1
- package/dist/components/auth/authorize/common/keys.d.ts +1 -2
- package/dist/components/auth/authorize/common/keys.d.ts.map +1 -1
- package/dist/components/auth/authorize/common/keys.js +3 -2
- package/dist/components/auth/authorize/common/keys.js.map +1 -1
- package/dist/components/auth/authorize/common/types.d.ts +88 -78
- package/dist/components/auth/authorize/common/types.d.ts.map +1 -1
- package/dist/components/auth/authorize/component.d.ts +1 -0
- package/dist/components/auth/authorize/component.d.ts.map +1 -1
- package/dist/components/auth/authorize/component.js +13 -33
- package/dist/components/auth/authorize/component.js.map +1 -1
- package/dist/components/auth/authorize/enforcers/casbin.enforcer.d.ts +45 -11
- package/dist/components/auth/authorize/enforcers/casbin.enforcer.d.ts.map +1 -1
- package/dist/components/auth/authorize/enforcers/casbin.enforcer.js +204 -35
- package/dist/components/auth/authorize/enforcers/casbin.enforcer.js.map +1 -1
- package/dist/components/auth/authorize/enforcers/enforcer-registry.d.ts +11 -6
- package/dist/components/auth/authorize/enforcers/enforcer-registry.d.ts.map +1 -1
- package/dist/components/auth/authorize/enforcers/enforcer-registry.js +28 -8
- package/dist/components/auth/authorize/enforcers/enforcer-registry.js.map +1 -1
- package/dist/components/auth/authorize/enforcers/index.d.ts +0 -1
- package/dist/components/auth/authorize/enforcers/index.d.ts.map +1 -1
- package/dist/components/auth/authorize/enforcers/index.js +0 -1
- package/dist/components/auth/authorize/enforcers/index.js.map +1 -1
- package/dist/components/auth/authorize/index.d.ts +1 -0
- package/dist/components/auth/authorize/index.d.ts.map +1 -1
- package/dist/components/auth/authorize/index.js +1 -0
- package/dist/components/auth/authorize/index.js.map +1 -1
- package/dist/components/auth/authorize/models/abilities/index.d.ts +3 -0
- package/dist/components/auth/authorize/models/abilities/index.d.ts.map +1 -0
- package/dist/components/auth/authorize/models/abilities/index.js +19 -0
- package/dist/components/auth/authorize/models/abilities/index.js.map +1 -0
- package/dist/components/auth/authorize/models/abilities/string-action.model.d.ts +14 -0
- package/dist/components/auth/authorize/models/abilities/string-action.model.d.ts.map +1 -0
- package/dist/components/auth/authorize/models/abilities/string-action.model.js +24 -0
- package/dist/components/auth/authorize/models/abilities/string-action.model.js.map +1 -0
- package/dist/components/auth/authorize/models/abilities/string-resource.model.d.ts +13 -0
- package/dist/components/auth/authorize/models/abilities/string-resource.model.d.ts.map +1 -0
- package/dist/components/auth/authorize/models/abilities/string-resource.model.js +20 -0
- package/dist/components/auth/authorize/models/abilities/string-resource.model.js.map +1 -0
- package/dist/components/auth/authorize/models/index.d.ts +1 -0
- package/dist/components/auth/authorize/models/index.d.ts.map +1 -1
- package/dist/components/auth/authorize/models/index.js +1 -0
- package/dist/components/auth/authorize/models/index.js.map +1 -1
- package/dist/components/auth/authorize/providers/authorization.provider.d.ts.map +1 -1
- package/dist/components/auth/authorize/providers/authorization.provider.js +44 -38
- package/dist/components/auth/authorize/providers/authorization.provider.js.map +1 -1
- package/dist/components/auth/base/abstract-auth-registry.d.ts +1 -0
- package/dist/components/auth/base/abstract-auth-registry.d.ts.map +1 -1
- package/dist/components/auth/base/abstract-auth-registry.js +3 -0
- package/dist/components/auth/base/abstract-auth-registry.js.map +1 -1
- package/dist/components/auth/context-variables.d.ts +14 -0
- package/dist/components/auth/context-variables.d.ts.map +1 -0
- package/dist/components/auth/context-variables.js +3 -0
- package/dist/components/auth/context-variables.js.map +1 -0
- package/dist/components/auth/index.d.ts +1 -0
- package/dist/components/auth/index.d.ts.map +1 -1
- package/dist/components/auth/index.js +1 -0
- package/dist/components/auth/index.js.map +1 -1
- package/dist/components/auth/models/entities/index.d.ts +1 -2
- package/dist/components/auth/models/entities/index.d.ts.map +1 -1
- package/dist/components/auth/models/entities/index.js +1 -2
- package/dist/components/auth/models/entities/index.js.map +1 -1
- package/dist/components/auth/models/entities/permission.model.d.ts +0 -1
- package/dist/components/auth/models/entities/permission.model.d.ts.map +1 -1
- package/dist/components/auth/models/entities/permission.model.js +0 -2
- package/dist/components/auth/models/entities/permission.model.js.map +1 -1
- package/dist/components/auth/models/entities/policy-definition.model.d.ts +24 -0
- package/dist/components/auth/models/entities/policy-definition.model.d.ts.map +1 -0
- package/dist/components/auth/models/entities/policy-definition.model.js +39 -0
- package/dist/components/auth/models/entities/policy-definition.model.js.map +1 -0
- package/dist/components/auth/models/entities/role.model.d.ts +3 -1
- package/dist/components/auth/models/entities/role.model.d.ts.map +1 -1
- package/dist/components/auth/models/entities/role.model.js +4 -1
- package/dist/components/auth/models/entities/role.model.js.map +1 -1
- package/dist/components/auth/models/entities/user.model.d.ts +4 -2
- package/dist/components/auth/models/entities/user.model.d.ts.map +1 -1
- package/dist/components/auth/models/entities/user.model.js +5 -3
- package/dist/components/auth/models/entities/user.model.js.map +1 -1
- package/package.json +97 -71
- package/dist/components/auth/authorize/enforcers/default.enforcer.d.ts +0 -37
- package/dist/components/auth/authorize/enforcers/default.enforcer.d.ts.map +0 -1
- package/dist/components/auth/authorize/enforcers/default.enforcer.js +0 -125
- package/dist/components/auth/authorize/enforcers/default.enforcer.js.map +0 -1
- package/dist/components/auth/models/entities/permission-mapping.model.d.ts +0 -26
- package/dist/components/auth/models/entities/permission-mapping.model.d.ts.map +0 -1
- package/dist/components/auth/models/entities/permission-mapping.model.js +0 -33
- package/dist/components/auth/models/entities/permission-mapping.model.js.map +0 -1
- package/dist/components/auth/models/entities/user-role.model.d.ts +0 -17
- package/dist/components/auth/models/entities/user-role.model.d.ts.map +0 -1
- package/dist/components/auth/models/entities/user-role.model.js +0 -34
- package/dist/components/auth/models/entities/user-role.model.js.map +0 -1
package/README.md
CHANGED
|
@@ -2,64 +2,2769 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@venizia/ignis)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://www.typescriptlang.org/)
|
|
6
|
+
[](https://hono.dev/)
|
|
7
|
+
[](https://orm.drizzle.team/)
|
|
5
8
|
|
|
6
|
-
The core package of the **Ignis Framework**
|
|
9
|
+
The core package of the **Ignis Framework** -- a high-performance TypeScript server infrastructure combining enterprise-grade architecture patterns with modern speed.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Philosophy
|
|
14
|
+
|
|
15
|
+
Ignis brings together the structured, enterprise development experience of **LoopBack 4** with the blazing speed and simplicity of **Hono**, giving you the best of both worlds:
|
|
16
|
+
|
|
17
|
+
- **LoopBack 4's architecture** -- decorator-based DI, repository pattern, DataSource abstraction, component system, boot conventions
|
|
18
|
+
- **Hono's raw speed** -- ~140k req/s on Bun, minimal overhead, native OpenAPI support
|
|
19
|
+
- **Drizzle ORM** -- type-safe SQL, zero-overhead queries, PostgreSQL-native features
|
|
20
|
+
|
|
21
|
+
Ignis targets growing APIs (10+ endpoints) that need structure without sacrificing performance. It makes the common case trivial and the complex case possible.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Table of Contents
|
|
26
|
+
|
|
27
|
+
- [Installation](#installation)
|
|
28
|
+
- [Quick Start](#quick-start)
|
|
29
|
+
- [Application Lifecycle](#application-lifecycle)
|
|
30
|
+
- [Application Configuration](#application-configuration)
|
|
31
|
+
- [Controllers](#controllers)
|
|
32
|
+
- [Repositories](#repositories)
|
|
33
|
+
- [Models](#models)
|
|
34
|
+
- [DataSources](#datasources)
|
|
35
|
+
- [Services](#services)
|
|
36
|
+
- [Components](#components)
|
|
37
|
+
- [Request Context](#request-context)
|
|
38
|
+
- [Middleware System](#middleware-system)
|
|
39
|
+
- [Error Handling](#error-handling)
|
|
40
|
+
- [Decorators Reference](#decorators-reference)
|
|
41
|
+
- [Response Helpers](#response-helpers)
|
|
42
|
+
- [Real-World Patterns](#real-world-patterns)
|
|
43
|
+
- [Testing](#testing)
|
|
44
|
+
- [Performance Tips](#performance-tips)
|
|
45
|
+
- [License](#license)
|
|
46
|
+
|
|
47
|
+
---
|
|
7
48
|
|
|
8
49
|
## Installation
|
|
9
50
|
|
|
10
51
|
```bash
|
|
11
52
|
bun add @venizia/ignis
|
|
12
|
-
# or
|
|
13
|
-
npm install @venizia/ignis
|
|
14
53
|
```
|
|
15
54
|
|
|
16
|
-
### Peer Dependencies
|
|
55
|
+
### Required Peer Dependencies
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
bun add hono @hono/zod-openapi drizzle-orm drizzle-zod pg jose @asteasolutions/zod-to-openapi
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Optional Peer Dependencies
|
|
62
|
+
|
|
63
|
+
Install only what you use:
|
|
17
64
|
|
|
18
65
|
```bash
|
|
19
|
-
|
|
66
|
+
# Swagger / API Reference UI
|
|
67
|
+
bun add @hono/swagger-ui @scalar/hono-api-reference
|
|
68
|
+
|
|
69
|
+
# Node.js runtime (if not using Bun)
|
|
70
|
+
bun add @hono/node-server
|
|
71
|
+
|
|
72
|
+
# Socket.IO real-time
|
|
73
|
+
bun add socket.io socket.io-client @socket.io/bun-engine
|
|
74
|
+
|
|
75
|
+
# Redis adapter for Socket.IO horizontal scaling
|
|
76
|
+
bun add @socket.io/redis-adapter @socket.io/redis-emitter
|
|
77
|
+
|
|
78
|
+
# Background job queues
|
|
79
|
+
bun add bullmq
|
|
80
|
+
|
|
81
|
+
# Authorization (Casbin RBAC)
|
|
82
|
+
bun add casbin
|
|
83
|
+
|
|
84
|
+
# Email
|
|
85
|
+
bun add nodemailer mailgun.js
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Quick Start
|
|
91
|
+
|
|
92
|
+
### 1. Define a Model
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
// models/user.model.ts
|
|
96
|
+
import { BaseEntity, model, generateIdColumnDefs, generateTzColumnDefs } from '@venizia/ignis';
|
|
97
|
+
import { pgTable, text } from 'drizzle-orm/pg-core';
|
|
98
|
+
|
|
99
|
+
@model({
|
|
100
|
+
type: 'entity',
|
|
101
|
+
settings: {
|
|
102
|
+
hiddenProperties: ['password'],
|
|
103
|
+
},
|
|
104
|
+
})
|
|
105
|
+
export class User extends BaseEntity<typeof User.schema> {
|
|
106
|
+
static override schema = pgTable('User', {
|
|
107
|
+
...generateIdColumnDefs({ id: { dataType: 'string' } }),
|
|
108
|
+
...generateTzColumnDefs(),
|
|
109
|
+
username: text('username').notNull().unique(),
|
|
110
|
+
email: text('email').notNull().unique(),
|
|
111
|
+
password: text('password'),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
static override relations = () => [];
|
|
115
|
+
}
|
|
20
116
|
```
|
|
21
117
|
|
|
22
|
-
|
|
118
|
+
### 2. Define a DataSource
|
|
23
119
|
|
|
24
120
|
```typescript
|
|
25
|
-
|
|
26
|
-
import {
|
|
121
|
+
// datasources/postgres.datasource.ts
|
|
122
|
+
import { BaseDataSource, datasource, ValueOrPromise } from '@venizia/ignis';
|
|
123
|
+
import { drizzle } from 'drizzle-orm/node-postgres';
|
|
124
|
+
import { Pool } from 'pg';
|
|
125
|
+
|
|
126
|
+
interface IDSConfigs {
|
|
127
|
+
host: string;
|
|
128
|
+
port: number;
|
|
129
|
+
database: string;
|
|
130
|
+
user: string;
|
|
131
|
+
password: string;
|
|
132
|
+
}
|
|
27
133
|
|
|
28
|
-
@
|
|
29
|
-
class
|
|
134
|
+
@datasource({ driver: 'node-postgres' })
|
|
135
|
+
export class PostgresDataSource extends BaseDataSource<IDSConfigs> {
|
|
30
136
|
constructor() {
|
|
31
|
-
super({
|
|
137
|
+
super({
|
|
138
|
+
name: PostgresDataSource.name,
|
|
139
|
+
config: {
|
|
140
|
+
host: process.env.DB_HOST!,
|
|
141
|
+
port: +(process.env.DB_PORT ?? 5432),
|
|
142
|
+
database: process.env.DB_NAME!,
|
|
143
|
+
user: process.env.DB_USER!,
|
|
144
|
+
password: process.env.DB_PASSWORD!,
|
|
145
|
+
},
|
|
146
|
+
// Schema is auto-discovered from @repository bindings
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
override configure(): ValueOrPromise<void> {
|
|
151
|
+
const schema = this.getSchema();
|
|
152
|
+
this.pool = new Pool(this.settings);
|
|
153
|
+
this.connector = drizzle({ client: this.pool, schema });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
override getConnectionString() {
|
|
157
|
+
const { host, port, user, password, database } = this.settings;
|
|
158
|
+
return `postgresql://${user}:${password}@${host}:${port}/${database}`;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### 3. Define a Repository
|
|
164
|
+
|
|
165
|
+
```typescript
|
|
166
|
+
// repositories/user.repository.ts
|
|
167
|
+
import { PersistableRepository, repository } from '@venizia/ignis';
|
|
168
|
+
import { User } from '../models/user.model';
|
|
169
|
+
import { PostgresDataSource } from '../datasources/postgres.datasource';
|
|
170
|
+
|
|
171
|
+
@repository({ model: User, dataSource: PostgresDataSource })
|
|
172
|
+
export class UserRepository extends PersistableRepository<typeof User.schema> {
|
|
173
|
+
// No constructor needed -- DataSource is auto-injected at param[0]
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### 4. Define a Controller
|
|
178
|
+
|
|
179
|
+
```typescript
|
|
180
|
+
// controllers/user.controller.ts
|
|
181
|
+
import {
|
|
182
|
+
BaseController, controller, get, post,
|
|
183
|
+
inject, jsonContent, jsonResponse, HTTP, TRouteContext,
|
|
184
|
+
} from '@venizia/ignis';
|
|
185
|
+
import { z } from '@hono/zod-openapi';
|
|
186
|
+
import { UserRepository } from '../repositories/user.repository';
|
|
187
|
+
|
|
188
|
+
@controller({ path: '/users' })
|
|
189
|
+
export class UserController extends BaseController {
|
|
190
|
+
constructor(
|
|
191
|
+
@inject({ key: 'repositories.UserRepository' }) private userRepo: UserRepository,
|
|
192
|
+
) {
|
|
193
|
+
super({ scope: UserController.name });
|
|
32
194
|
}
|
|
33
195
|
|
|
34
196
|
override binding() {}
|
|
35
197
|
|
|
36
198
|
@get({
|
|
37
199
|
configs: {
|
|
38
|
-
path:
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
200
|
+
path: '/',
|
|
201
|
+
responses: jsonResponse({
|
|
202
|
+
schema: z.array(z.object({ id: z.string(), username: z.string(), email: z.string() })),
|
|
203
|
+
}),
|
|
204
|
+
},
|
|
205
|
+
})
|
|
206
|
+
async listUsers(context: TRouteContext) {
|
|
207
|
+
const users = await this.userRepo.find({ filter: {} });
|
|
208
|
+
return context.json(users, 200);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
@post({
|
|
212
|
+
configs: {
|
|
213
|
+
path: '/',
|
|
214
|
+
request: {
|
|
215
|
+
body: jsonContent({
|
|
216
|
+
description: 'New user data',
|
|
217
|
+
schema: z.object({ username: z.string(), email: z.string(), password: z.string() }),
|
|
44
218
|
}),
|
|
45
219
|
},
|
|
220
|
+
responses: jsonResponse({
|
|
221
|
+
schema: z.object({ count: z.number(), data: z.any() }),
|
|
222
|
+
}),
|
|
46
223
|
},
|
|
47
224
|
})
|
|
48
|
-
|
|
49
|
-
|
|
225
|
+
async createUser(context: TRouteContext) {
|
|
226
|
+
const body = context.req.valid<{ username: string; email: string; password: string }>('json');
|
|
227
|
+
const result = await this.userRepo.create({ data: body });
|
|
228
|
+
return context.json(result, 200);
|
|
50
229
|
}
|
|
51
230
|
}
|
|
52
231
|
```
|
|
53
232
|
|
|
54
|
-
|
|
233
|
+
### 5. Define the Application
|
|
55
234
|
|
|
56
|
-
|
|
235
|
+
```typescript
|
|
236
|
+
// application.ts
|
|
237
|
+
import {
|
|
238
|
+
BaseApplication, IApplicationConfigs, IApplicationInfo,
|
|
239
|
+
HealthCheckComponent, SwaggerComponent, ValueOrPromise,
|
|
240
|
+
} from '@venizia/ignis';
|
|
241
|
+
import { PostgresDataSource } from './datasources/postgres.datasource';
|
|
242
|
+
import { UserRepository } from './repositories/user.repository';
|
|
243
|
+
import { UserController } from './controllers/user.controller';
|
|
57
244
|
|
|
58
|
-
|
|
245
|
+
const configs: IApplicationConfigs = {
|
|
246
|
+
host: 'localhost',
|
|
247
|
+
port: 3000,
|
|
248
|
+
path: { base: '/api', isStrict: true },
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
export class Application extends BaseApplication {
|
|
252
|
+
constructor() {
|
|
253
|
+
super({ scope: Application.name, config: configs });
|
|
254
|
+
this.init();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
getAppInfo(): IApplicationInfo {
|
|
258
|
+
return { name: 'My App', version: '1.0.0', description: 'My Ignis application' };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
staticConfigure() {}
|
|
262
|
+
|
|
263
|
+
preConfigure(): ValueOrPromise<void> {
|
|
264
|
+
// Register components
|
|
265
|
+
this.component(HealthCheckComponent);
|
|
266
|
+
this.component(SwaggerComponent);
|
|
267
|
+
|
|
268
|
+
// Register datasources, repositories, and controllers
|
|
269
|
+
this.dataSource(PostgresDataSource);
|
|
270
|
+
this.repository(UserRepository);
|
|
271
|
+
this.controller(UserController);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
postConfigure(): ValueOrPromise<void> {}
|
|
275
|
+
|
|
276
|
+
setupMiddlewares(): ValueOrPromise<void> {
|
|
277
|
+
// Add CORS, body limit, etc.
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
### 6. Start the Server
|
|
283
|
+
|
|
284
|
+
```typescript
|
|
285
|
+
// index.ts
|
|
286
|
+
import { Application } from './application';
|
|
287
|
+
|
|
288
|
+
const app = new Application();
|
|
289
|
+
app.start();
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
---
|
|
293
|
+
|
|
294
|
+
## Application Lifecycle
|
|
295
|
+
|
|
296
|
+
`BaseApplication` extends the IoC `Container` and orchestrates a well-defined startup sequence:
|
|
297
|
+
|
|
298
|
+
```
|
|
299
|
+
1. init() Register core bindings (app instance, server, root router)
|
|
300
|
+
2. start() Entry point -- calls initialize() then starts the server
|
|
301
|
+
|
|
|
302
|
+
+-- initialize()
|
|
303
|
+
| |
|
|
304
|
+
| +-- printStartUpInfo() Log environment, runtime, timezone, datasource info
|
|
305
|
+
| +-- validateEnvs() Validate required environment variables
|
|
306
|
+
| +-- registerDefaultMiddlewares() Error handler, async context, request tracker, favicon
|
|
307
|
+
| +-- staticConfigure() Pre-DI static setup (e.g., serve static files)
|
|
308
|
+
| +-- preConfigure() Register controllers, services, components, datasources
|
|
309
|
+
| +-- registerDataSources() Configure all datasources (auto-discover schemas)
|
|
310
|
+
| +-- registerComponents() Configure all components (can register more datasources)
|
|
311
|
+
| +-- registerControllers() Configure controllers, mount routes on root router
|
|
312
|
+
| +-- postConfigure() Post-registration hooks
|
|
313
|
+
|
|
|
314
|
+
+-- setupMiddlewares() Register Hono middlewares (CORS, body limit, etc.)
|
|
315
|
+
+-- mount root router Mount to base path
|
|
316
|
+
+-- startBunModule / startNodeModule Start HTTP server
|
|
317
|
+
+-- executePostStartHooks() Run any registered post-start hooks
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
### What Happens Inside Each Phase
|
|
321
|
+
|
|
322
|
+
**`registerDefaultMiddlewares()`** -- Automatically sets up:
|
|
323
|
+
|
|
324
|
+
- `appErrorHandler` -- Global error handler that catches all errors, formats them as JSON, handles ZodError validation errors (returns 422), recognizes PostgreSQL constraint violations (returns 400 instead of 500), and strips stack traces in production.
|
|
325
|
+
- `contextStorage()` -- Hono async context storage for accessing request context anywhere (enabled by default, controlled via `asyncContext.enable` config).
|
|
326
|
+
- `RequestTrackerComponent` -- Injects `x-request-id` header on every request and parses request body.
|
|
327
|
+
- `emojiFavicon` -- Returns a favicon emoji response (configurable via `favicon` config).
|
|
328
|
+
- `notFoundHandler` -- Returns a structured 404 response for unmatched routes.
|
|
329
|
+
|
|
330
|
+
**`registerDataSources()`** -- Iterates all bindings tagged `datasources`, calls `configure()` on each. Schema auto-discovery happens here.
|
|
331
|
+
|
|
332
|
+
**`registerComponents()`** -- Iterates all bindings tagged `components`, calls `configure()` on each. Components can register additional datasources during their configuration (the method re-fetches bindings after each component to pick up dynamically added datasources).
|
|
333
|
+
|
|
334
|
+
**`registerControllers()`** -- Iterates all bindings tagged `controllers`. For each: validates that `@controller` metadata has a `path`, calls `configure()` (which triggers `binding()` and `registerRoutesFromRegistry()`), then mounts the controller's router at its configured path on the root router.
|
|
335
|
+
|
|
336
|
+
### Key Application Methods
|
|
337
|
+
|
|
338
|
+
| Method | Description |
|
|
339
|
+
|--------|-------------|
|
|
340
|
+
| `controller(ctor)` | Register a controller class -- bound to `controllers.{Name}` |
|
|
341
|
+
| `service(ctor)` | Register a service class -- bound to `services.{Name}` |
|
|
342
|
+
| `repository(ctor)` | Register a repository class -- bound to `repositories.{Name}` |
|
|
343
|
+
| `dataSource(ctor)` | Register a datasource class (singleton) -- bound to `datasources.{Name}` |
|
|
344
|
+
| `component(ctor)` | Register a component class (singleton) -- bound to `components.{Name}` |
|
|
345
|
+
| `static({ folderPath })` | Serve static files (auto-detects Bun/Node runtime) |
|
|
346
|
+
| `getServer()` | Get the main `OpenAPIHono` instance |
|
|
347
|
+
| `getServerPort()` | Get the configured server port |
|
|
348
|
+
| `getServerHost()` | Get the configured server host |
|
|
349
|
+
| `getServerAddress()` | Get `host:port` string |
|
|
350
|
+
| `getRootRouter()` | Get the root router for direct route registration |
|
|
351
|
+
| `getProjectRoot()` | Get the project working directory |
|
|
352
|
+
| `getProjectConfigs()` | Get the full application configuration object |
|
|
353
|
+
| `getServerInstance()` | Get the underlying Bun.Server or Node HTTP server instance |
|
|
354
|
+
| `registerPostStartHook({ identifier, hook })` | Register a callback to run after server starts |
|
|
355
|
+
| `boot()` | Convention-based auto-discovery (controllers, services, repositories, datasources) |
|
|
356
|
+
| `stop()` | Gracefully stop the server |
|
|
357
|
+
|
|
358
|
+
### `registerDynamicBindings()` -- Handling Late/Circular Registrations
|
|
359
|
+
|
|
360
|
+
The `registerDynamicBindings()` method is the engine behind `registerDataSources()`, `registerComponents()`, and `registerControllers()`. It handles the case where configuring one binding may register new bindings of the same type:
|
|
361
|
+
|
|
362
|
+
```typescript
|
|
363
|
+
protected async registerDynamicBindings<T extends IConfigurable>(opts: {
|
|
364
|
+
namespace: TBindingNamespace;
|
|
365
|
+
onBeforeConfigure?: (opts: { binding: Binding<T> }) => Promise<void>;
|
|
366
|
+
onAfterConfigure?: (opts: { binding: Binding<T>; instance: T }) => Promise<void>;
|
|
367
|
+
}): Promise<void>;
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
It works by:
|
|
371
|
+
|
|
372
|
+
1. Fetching all bindings for the given namespace, excluding already-configured ones.
|
|
373
|
+
2. Configuring each binding in sequence.
|
|
374
|
+
3. After each configuration, re-fetching bindings to pick up any newly added ones.
|
|
375
|
+
4. Repeating until no new bindings remain.
|
|
376
|
+
|
|
377
|
+
This is critical for components that register datasources during their own configuration.
|
|
378
|
+
|
|
379
|
+
### `registerPostStartHook()` -- Running Code After Server Start
|
|
380
|
+
|
|
381
|
+
```typescript
|
|
382
|
+
// In preConfigure() or postConfigure():
|
|
383
|
+
this.registerPostStartHook({
|
|
384
|
+
identifier: 'warmup-cache',
|
|
385
|
+
hook: async () => {
|
|
386
|
+
const cacheService = this.get<CacheService>({ key: 'services.CacheService' });
|
|
387
|
+
await cacheService.warmup();
|
|
388
|
+
console.log('Cache warmed up');
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
this.registerPostStartHook({
|
|
393
|
+
identifier: 'register-cron-jobs',
|
|
394
|
+
hook: async () => {
|
|
395
|
+
const cronService = this.get<CronService>({ key: 'services.CronService' });
|
|
396
|
+
cronService.startAll();
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
Post-start hooks execute sequentially after the HTTP server is listening. Each hook is logged with its execution time.
|
|
402
|
+
|
|
403
|
+
### Static File Serving
|
|
404
|
+
|
|
405
|
+
```typescript
|
|
406
|
+
staticConfigure() {
|
|
407
|
+
// Serve files from ./public directory at all unmatched routes
|
|
408
|
+
this.static({ folderPath: './public' });
|
|
409
|
+
|
|
410
|
+
// Or serve at a specific path
|
|
411
|
+
this.static({ restPath: '/assets/*', folderPath: './static-assets' });
|
|
412
|
+
}
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
Runtime-aware: uses `hono/bun` `serveStatic` on Bun, `@hono/node-server/serve-static` on Node.js.
|
|
416
|
+
|
|
417
|
+
### Runtime Detection
|
|
418
|
+
|
|
419
|
+
Ignis auto-detects the runtime and starts the server accordingly:
|
|
420
|
+
|
|
421
|
+
```typescript
|
|
422
|
+
// Bun (default)
|
|
423
|
+
Bun.serve({ port, hostname, fetch: server.fetch });
|
|
424
|
+
|
|
425
|
+
// Node.js (requires @hono/node-server)
|
|
426
|
+
import { serve } from '@hono/node-server';
|
|
427
|
+
serve({ fetch: server.fetch, port, hostname });
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
The runtime is detected via `RuntimeModules.detect()` which checks for the presence of global `Bun` object.
|
|
431
|
+
|
|
432
|
+
---
|
|
433
|
+
|
|
434
|
+
## Application Configuration
|
|
435
|
+
|
|
436
|
+
```typescript
|
|
437
|
+
interface IApplicationConfigs {
|
|
438
|
+
host?: string; // Server host (default: 'localhost' or APP_ENV_SERVER_HOST env)
|
|
439
|
+
port?: number; // Server port (default: 3000 or PORT/APP_ENV_SERVER_PORT env)
|
|
440
|
+
|
|
441
|
+
path: {
|
|
442
|
+
base: string; // Base path for all routes (e.g., '/api')
|
|
443
|
+
isStrict: boolean; // When true, '/users' and '/users/' are different routes
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
requestId?: {
|
|
447
|
+
isStrict: boolean; // Enforce request ID on all requests
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
favicon?: string; // Emoji favicon (default: fire emoji)
|
|
451
|
+
|
|
452
|
+
error?: {
|
|
453
|
+
rootKey: string; // Wrap error responses in this key (e.g., 'error')
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
asyncContext?: {
|
|
457
|
+
enable: boolean; // Enable Hono async context storage (default: true)
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
bootOptions?: IBootOptions; // Convention-based auto-discovery options
|
|
461
|
+
|
|
462
|
+
debug?: {
|
|
463
|
+
shouldShowRoutes?: boolean; // Print all registered routes on startup
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
```typescript
|
|
469
|
+
interface IApplicationInfo {
|
|
470
|
+
name: string;
|
|
471
|
+
version: string;
|
|
472
|
+
description: string;
|
|
473
|
+
author?: { name: string; email: string; url?: string };
|
|
474
|
+
}
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
---
|
|
478
|
+
|
|
479
|
+
## Controllers
|
|
480
|
+
|
|
481
|
+
### BaseController
|
|
482
|
+
|
|
483
|
+
All controllers extend `BaseController`, which provides:
|
|
484
|
+
|
|
485
|
+
- An `OpenAPIHono` router instance
|
|
486
|
+
- Route registration methods (`defineRoute`, `bindRoute`, `defineJSXRoute`)
|
|
487
|
+
- Automatic authentication and authorization middleware injection
|
|
488
|
+
- OpenAPI schema generation and route tagging
|
|
489
|
+
- Zod-based request validation with automatic 422 error responses
|
|
490
|
+
|
|
491
|
+
```typescript
|
|
492
|
+
abstract class BaseController extends AbstractController {
|
|
493
|
+
// Register routes -- override this method
|
|
494
|
+
abstract binding(): ValueOrPromise<void>;
|
|
495
|
+
|
|
496
|
+
// Imperative route definition
|
|
497
|
+
defineRoute({ configs, handler, hook? });
|
|
498
|
+
|
|
499
|
+
// Fluent two-step route definition
|
|
500
|
+
bindRoute({ configs }).to({ handler });
|
|
501
|
+
|
|
502
|
+
// JSX/HTML route definition (server-side rendering)
|
|
503
|
+
defineJSXRoute({ configs, handler });
|
|
504
|
+
|
|
505
|
+
// Get the router for this controller
|
|
506
|
+
getRouter(): OpenAPIHono;
|
|
507
|
+
}
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
### Three Route Definition Patterns
|
|
511
|
+
|
|
512
|
+
#### 1. Decorator Pattern
|
|
513
|
+
|
|
514
|
+
Use `@get`, `@post`, `@put`, `@patch`, `@del`, or the generic `@api` decorators. Decorator-based routes are automatically registered during `configure()` via `registerRoutesFromRegistry()`:
|
|
515
|
+
|
|
516
|
+
```typescript
|
|
517
|
+
@controller({ path: '/products' })
|
|
518
|
+
class ProductController extends BaseController {
|
|
519
|
+
constructor(
|
|
520
|
+
@inject({ key: 'repositories.ProductRepository' }) private productRepo: ProductRepository,
|
|
521
|
+
@inject({ key: 'services.InventoryService' }) private inventoryService: InventoryService,
|
|
522
|
+
) {
|
|
523
|
+
super({ scope: ProductController.name });
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
override binding() {} // decorator routes are auto-registered
|
|
527
|
+
|
|
528
|
+
@get({
|
|
529
|
+
configs: {
|
|
530
|
+
path: '/',
|
|
531
|
+
description: 'List all products with pagination',
|
|
532
|
+
responses: jsonResponse({
|
|
533
|
+
schema: z.array(z.object({
|
|
534
|
+
id: z.number(),
|
|
535
|
+
name: z.string(),
|
|
536
|
+
price: z.number(),
|
|
537
|
+
category: z.string(),
|
|
538
|
+
})),
|
|
539
|
+
description: 'Array of products',
|
|
540
|
+
}),
|
|
541
|
+
},
|
|
542
|
+
})
|
|
543
|
+
async list(context: TRouteContext) {
|
|
544
|
+
const products = await this.productRepo.find({
|
|
545
|
+
filter: { order: ['createdAt DESC'], limit: 20 },
|
|
546
|
+
});
|
|
547
|
+
return context.json(products, 200);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
@get({
|
|
551
|
+
configs: {
|
|
552
|
+
path: '/{id}',
|
|
553
|
+
request: {
|
|
554
|
+
params: z.object({ id: z.string().pipe(z.coerce.number()) }),
|
|
555
|
+
},
|
|
556
|
+
responses: jsonResponse({
|
|
557
|
+
schema: z.object({
|
|
558
|
+
id: z.number(),
|
|
559
|
+
name: z.string(),
|
|
560
|
+
price: z.number(),
|
|
561
|
+
stock: z.number(),
|
|
562
|
+
}),
|
|
563
|
+
}),
|
|
564
|
+
},
|
|
565
|
+
})
|
|
566
|
+
async getById(context: TRouteContext) {
|
|
567
|
+
const { id } = context.req.valid<{ id: number }>('param');
|
|
568
|
+
const product = await this.productRepo.findById({ id });
|
|
569
|
+
if (!product) {
|
|
570
|
+
return context.json({ message: 'Product not found' }, 404);
|
|
571
|
+
}
|
|
572
|
+
return context.json(product, 200);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
@post({
|
|
576
|
+
configs: {
|
|
577
|
+
path: '/',
|
|
578
|
+
authenticate: { strategies: ['jwt'] },
|
|
579
|
+
request: {
|
|
580
|
+
body: jsonContent({
|
|
581
|
+
schema: z.object({
|
|
582
|
+
name: z.string().min(1).max(255),
|
|
583
|
+
price: z.number().positive(),
|
|
584
|
+
category: z.string(),
|
|
585
|
+
description: z.string().optional(),
|
|
586
|
+
}),
|
|
587
|
+
description: 'New product data',
|
|
588
|
+
}),
|
|
589
|
+
},
|
|
590
|
+
responses: jsonResponse({
|
|
591
|
+
schema: z.object({ count: z.number(), data: z.any() }),
|
|
592
|
+
}),
|
|
593
|
+
},
|
|
594
|
+
})
|
|
595
|
+
async create(context: TRouteContext) {
|
|
596
|
+
const data = context.req.valid<{
|
|
597
|
+
name: string;
|
|
598
|
+
price: number;
|
|
599
|
+
category: string;
|
|
600
|
+
description?: string;
|
|
601
|
+
}>('json');
|
|
602
|
+
const result = await this.productRepo.create({ data });
|
|
603
|
+
return context.json(result, 200);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
#### 2. Imperative Pattern
|
|
609
|
+
|
|
610
|
+
Define routes directly inside `binding()`:
|
|
611
|
+
|
|
612
|
+
```typescript
|
|
613
|
+
override binding() {
|
|
614
|
+
this.defineRoute({
|
|
615
|
+
configs: {
|
|
616
|
+
path: '/',
|
|
617
|
+
method: 'get',
|
|
618
|
+
description: 'List products',
|
|
619
|
+
responses: jsonResponse({ schema: z.array(ProductSchema) }),
|
|
620
|
+
},
|
|
621
|
+
handler: async (context) => {
|
|
622
|
+
const products = await this.productRepo.find({ filter: {} });
|
|
623
|
+
return context.json(products, 200);
|
|
624
|
+
},
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
this.defineRoute({
|
|
628
|
+
configs: {
|
|
629
|
+
path: '/{id}',
|
|
630
|
+
method: 'delete',
|
|
631
|
+
authenticate: { strategies: ['jwt'] },
|
|
632
|
+
authorize: { action: 'delete', resource: 'Product' },
|
|
633
|
+
request: { params: idParamsSchema({ idType: 'number' }) },
|
|
634
|
+
responses: jsonResponse({ schema: z.object({ count: z.number() }) }),
|
|
635
|
+
},
|
|
636
|
+
handler: async (context) => {
|
|
637
|
+
const { id } = context.req.valid<{ id: number }>('param');
|
|
638
|
+
const result = await this.productRepo.deleteById({ id });
|
|
639
|
+
return context.json(result, 200);
|
|
640
|
+
},
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
#### 3. Fluent Pattern
|
|
646
|
+
|
|
647
|
+
Two-step binding with `bindRoute().to()`:
|
|
648
|
+
|
|
649
|
+
```typescript
|
|
650
|
+
override binding() {
|
|
651
|
+
this.bindRoute({
|
|
652
|
+
configs: {
|
|
653
|
+
path: '/{id}',
|
|
654
|
+
method: 'get',
|
|
655
|
+
request: { params: idParamsSchema({ idType: 'number' }) },
|
|
656
|
+
responses: jsonResponse({ schema: ProductSchema }),
|
|
657
|
+
},
|
|
658
|
+
}).to({
|
|
659
|
+
handler: async (context) => {
|
|
660
|
+
const { id } = context.req.valid<{ id: number }>('param');
|
|
661
|
+
const product = await this.productRepo.findById({ id });
|
|
662
|
+
return context.json(product, 200);
|
|
663
|
+
},
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
### `getRouteConfigs()` -- How Auth Middleware is Injected
|
|
669
|
+
|
|
670
|
+
When you specify `authenticate` or `authorize` on a route config, `getRouteConfigs()` automatically:
|
|
671
|
+
|
|
672
|
+
1. Converts `authenticate.strategies` into OpenAPI security specs for documentation.
|
|
673
|
+
2. Creates an `authenticate` middleware based on strategies and mode, and prepends it to the middleware chain.
|
|
674
|
+
3. Creates an `authorize` middleware (if configured) and appends it after authenticate.
|
|
675
|
+
4. Merges any custom `middleware` array from the config.
|
|
676
|
+
5. Adds the controller's scope name as an OpenAPI tag.
|
|
677
|
+
|
|
678
|
+
This means you never manually wire auth middleware -- it is all declarative.
|
|
679
|
+
|
|
680
|
+
### Middleware Chaining on Routes
|
|
681
|
+
|
|
682
|
+
You can pass additional Hono middleware to any route:
|
|
683
|
+
|
|
684
|
+
```typescript
|
|
685
|
+
import { rateLimiter } from 'hono/rate-limiter';
|
|
686
|
+
import { cors } from 'hono/cors';
|
|
687
|
+
|
|
688
|
+
@post({
|
|
689
|
+
configs: {
|
|
690
|
+
path: '/upload',
|
|
691
|
+
middleware: [
|
|
692
|
+
rateLimiter({ windowMs: 60_000, limit: 10 }),
|
|
693
|
+
cors({ origin: 'https://myapp.com' }),
|
|
694
|
+
],
|
|
695
|
+
authenticate: { strategies: ['jwt'] },
|
|
696
|
+
// ...
|
|
697
|
+
},
|
|
698
|
+
})
|
|
699
|
+
async uploadFile(context: TRouteContext) { /* ... */ }
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
Middleware execution order: authenticate -> authorize -> custom middleware -> handler.
|
|
703
|
+
|
|
704
|
+
### Request Validation with Zod
|
|
705
|
+
|
|
706
|
+
Routes automatically validate request parameters, query strings, headers, and body against Zod schemas. Invalid requests return a 422 Unprocessable Entity with structured error details:
|
|
707
|
+
|
|
708
|
+
```typescript
|
|
709
|
+
@post({
|
|
710
|
+
configs: {
|
|
711
|
+
path: '/',
|
|
712
|
+
request: {
|
|
713
|
+
body: jsonContent({
|
|
714
|
+
schema: z.object({
|
|
715
|
+
email: z.string().email('Invalid email format'),
|
|
716
|
+
age: z.number().int().min(18, 'Must be at least 18'),
|
|
717
|
+
role: z.enum(['admin', 'user', 'moderator']),
|
|
718
|
+
}),
|
|
719
|
+
}),
|
|
720
|
+
query: z.object({
|
|
721
|
+
dryRun: z.string().optional().transform(v => v === 'true'),
|
|
722
|
+
}),
|
|
723
|
+
headers: z.object({
|
|
724
|
+
'x-api-key': z.string().min(1),
|
|
725
|
+
}),
|
|
726
|
+
},
|
|
727
|
+
responses: jsonResponse({ schema: UserSchema }),
|
|
728
|
+
},
|
|
729
|
+
})
|
|
730
|
+
async createUser(context: TRouteContext) {
|
|
731
|
+
const body = context.req.valid<{ email: string; age: number; role: string }>('json');
|
|
732
|
+
const { dryRun } = context.req.valid<{ dryRun?: boolean }>('query');
|
|
733
|
+
const apiKey = context.req.valid<{ 'x-api-key': string }>('header');
|
|
734
|
+
// All validated -- proceed safely
|
|
735
|
+
}
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
On validation failure, the error handler returns:
|
|
739
|
+
|
|
740
|
+
```json
|
|
741
|
+
{
|
|
742
|
+
"message": "ValidationError",
|
|
743
|
+
"statusCode": 422,
|
|
744
|
+
"requestId": "abc-123",
|
|
745
|
+
"details": {
|
|
746
|
+
"cause": [
|
|
747
|
+
{ "path": "email", "message": "Invalid email format", "code": "invalid_string" },
|
|
748
|
+
{ "path": "age", "message": "Must be at least 18", "code": "too_small" }
|
|
749
|
+
]
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
```
|
|
753
|
+
|
|
754
|
+
### Accessing Hono Context
|
|
755
|
+
|
|
756
|
+
The `context` parameter (`TRouteContext`) provides full access to the Hono request/response:
|
|
757
|
+
|
|
758
|
+
```typescript
|
|
759
|
+
async myHandler(context: TRouteContext) {
|
|
760
|
+
// Request data
|
|
761
|
+
const body = context.req.valid<MyType>('json');
|
|
762
|
+
const params = context.req.valid<{ id: number }>('param');
|
|
763
|
+
const query = context.req.valid<{ page: number }>('query');
|
|
764
|
+
|
|
765
|
+
// Raw request access
|
|
766
|
+
const url = context.req.url;
|
|
767
|
+
const method = context.req.method;
|
|
768
|
+
const path = context.req.path;
|
|
769
|
+
const userAgent = context.req.header('user-agent');
|
|
770
|
+
const allHeaders = context.req.raw.headers;
|
|
771
|
+
|
|
772
|
+
// Authenticated user (set by auth middleware)
|
|
773
|
+
const currentUser = context.get('auth.current.user');
|
|
774
|
+
const auditUserId = context.get('audit.user.id');
|
|
775
|
+
|
|
776
|
+
// Set response headers
|
|
777
|
+
context.header('X-Custom-Header', 'value');
|
|
778
|
+
context.header('Cache-Control', 'no-store');
|
|
779
|
+
|
|
780
|
+
// Response types
|
|
781
|
+
return context.json({ data: 'value' }, 200);
|
|
782
|
+
return context.text('plain text', 200);
|
|
783
|
+
return context.html('<h1>Hello</h1>');
|
|
784
|
+
return context.redirect('/other-page');
|
|
785
|
+
return context.body(null, 204); // No content
|
|
786
|
+
}
|
|
787
|
+
```
|
|
788
|
+
|
|
789
|
+
### File Upload Handling
|
|
790
|
+
|
|
791
|
+
```typescript
|
|
792
|
+
@post({
|
|
793
|
+
configs: {
|
|
794
|
+
path: '/upload',
|
|
795
|
+
authenticate: { strategies: ['jwt'] },
|
|
796
|
+
responses: jsonResponse({ schema: z.object({ filename: z.string(), size: z.number() }) }),
|
|
797
|
+
},
|
|
798
|
+
})
|
|
799
|
+
async upload(context: TRouteContext) {
|
|
800
|
+
const body = await context.req.parseBody();
|
|
801
|
+
const file = body['file'];
|
|
802
|
+
|
|
803
|
+
if (file instanceof File) {
|
|
804
|
+
const buffer = await file.arrayBuffer();
|
|
805
|
+
// Process file...
|
|
806
|
+
return context.json({ filename: file.name, size: file.size }, 200);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
return context.json({ message: 'No file provided' }, 400);
|
|
810
|
+
}
|
|
811
|
+
```
|
|
812
|
+
|
|
813
|
+
### Streaming Responses
|
|
814
|
+
|
|
815
|
+
```typescript
|
|
816
|
+
@get({
|
|
817
|
+
configs: {
|
|
818
|
+
path: '/stream',
|
|
819
|
+
responses: { 200: { description: 'Streamed response' } },
|
|
820
|
+
},
|
|
821
|
+
})
|
|
822
|
+
async streamData(context: TRouteContext) {
|
|
823
|
+
return context.body(
|
|
824
|
+
new ReadableStream({
|
|
825
|
+
start(controller) {
|
|
826
|
+
controller.enqueue(new TextEncoder().encode('chunk 1\n'));
|
|
827
|
+
setTimeout(() => {
|
|
828
|
+
controller.enqueue(new TextEncoder().encode('chunk 2\n'));
|
|
829
|
+
controller.close();
|
|
830
|
+
}, 1000);
|
|
831
|
+
},
|
|
832
|
+
}),
|
|
833
|
+
200,
|
|
834
|
+
{ 'Content-Type': 'text/plain' },
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
```
|
|
838
|
+
|
|
839
|
+
### JSX Server-Side Rendering
|
|
840
|
+
|
|
841
|
+
```typescript
|
|
842
|
+
this.defineJSXRoute({
|
|
843
|
+
configs: {
|
|
844
|
+
path: '/profile',
|
|
845
|
+
method: 'get',
|
|
846
|
+
description: 'User profile page',
|
|
847
|
+
authenticate: { strategies: ['jwt'] },
|
|
848
|
+
},
|
|
849
|
+
handler: (context) => {
|
|
850
|
+
const user = context.get('auth.current.user');
|
|
851
|
+
return context.html(<ProfilePage user={user} />);
|
|
852
|
+
},
|
|
853
|
+
});
|
|
854
|
+
```
|
|
855
|
+
|
|
856
|
+
### Route Decorators
|
|
857
|
+
|
|
858
|
+
| Decorator | Description |
|
|
859
|
+
|-----------|-------------|
|
|
860
|
+
| `@controller({ path, authenticate? })` | Class decorator -- registers controller path and optional default auth |
|
|
861
|
+
| `@get({ configs })` | GET route -- method is set automatically |
|
|
862
|
+
| `@post({ configs })` | POST route |
|
|
863
|
+
| `@put({ configs })` | PUT route |
|
|
864
|
+
| `@patch({ configs })` | PATCH route |
|
|
865
|
+
| `@del({ configs })` | DELETE route |
|
|
866
|
+
| `@api({ configs })` | Generic route -- specify method in configs |
|
|
867
|
+
|
|
868
|
+
### Route Configuration
|
|
869
|
+
|
|
870
|
+
```typescript
|
|
871
|
+
interface IAuthRouteConfig extends HonoRouteConfig {
|
|
872
|
+
path: string;
|
|
873
|
+
method: 'get' | 'post' | 'put' | 'patch' | 'delete';
|
|
874
|
+
description?: string;
|
|
875
|
+
tags?: string[];
|
|
876
|
+
|
|
877
|
+
// Authentication
|
|
878
|
+
authenticate?: {
|
|
879
|
+
strategies?: ('jwt' | 'basic')[];
|
|
880
|
+
mode?: 'any' | 'all';
|
|
881
|
+
};
|
|
882
|
+
|
|
883
|
+
// Authorization (Casbin RBAC)
|
|
884
|
+
authorize?: IAuthorizationSpec | IAuthorizationSpec[];
|
|
885
|
+
|
|
886
|
+
// Request schema validation
|
|
887
|
+
request?: {
|
|
888
|
+
body?: ContentConfig;
|
|
889
|
+
query?: ZodSchema;
|
|
890
|
+
params?: ZodSchema;
|
|
891
|
+
headers?: ZodSchema;
|
|
892
|
+
};
|
|
893
|
+
|
|
894
|
+
// Response schema
|
|
895
|
+
responses: Record<number | string, ResponseConfig>;
|
|
896
|
+
|
|
897
|
+
// Additional Hono middleware
|
|
898
|
+
middleware?: MiddlewareHandler[];
|
|
899
|
+
}
|
|
900
|
+
```
|
|
901
|
+
|
|
902
|
+
### Controller Factory
|
|
903
|
+
|
|
904
|
+
Auto-generate a full CRUD controller from an entity definition:
|
|
905
|
+
|
|
906
|
+
```typescript
|
|
907
|
+
import { ControllerFactory } from '@venizia/ignis';
|
|
908
|
+
|
|
909
|
+
const UserCrudController = ControllerFactory.defineCrudController({
|
|
910
|
+
entity: User,
|
|
911
|
+
repository: { name: 'UserRepository' },
|
|
912
|
+
controller: {
|
|
913
|
+
name: 'UserCrudController',
|
|
914
|
+
basePath: '/users',
|
|
915
|
+
isStrict: {
|
|
916
|
+
path: true, // Strict path matching
|
|
917
|
+
requestSchema: true, // Strict Zod request validation
|
|
918
|
+
},
|
|
919
|
+
},
|
|
920
|
+
authenticate: { strategies: ['jwt'] },
|
|
921
|
+
authorize: { action: 'manage', resource: 'User' },
|
|
922
|
+
routes: {
|
|
923
|
+
find: { authenticate: { skip: true } }, // Public read -- also skips authorization
|
|
924
|
+
findById: { authenticate: { skip: true } }, // Public read
|
|
925
|
+
count: { authenticate: { skip: true } }, // Public read
|
|
926
|
+
create: {
|
|
927
|
+
request: { body: CustomCreateSchema }, // Override request body schema
|
|
928
|
+
},
|
|
929
|
+
deleteById: {
|
|
930
|
+
authorize: { action: 'delete', resource: 'User' }, // Override authorization
|
|
931
|
+
},
|
|
932
|
+
},
|
|
933
|
+
});
|
|
934
|
+
```
|
|
935
|
+
|
|
936
|
+
This generates the following endpoints:
|
|
937
|
+
|
|
938
|
+
| Method | Path | Description |
|
|
939
|
+
|--------|------|-------------|
|
|
940
|
+
| `GET` | `/count` | Count records matching where condition |
|
|
941
|
+
| `GET` | `/` | Find all records (paginated, with Content-Range header) |
|
|
942
|
+
| `GET` | `/{id}` | Find record by ID |
|
|
943
|
+
| `GET` | `/find-one` | Find first matching record |
|
|
944
|
+
| `POST` | `/` | Create new record |
|
|
945
|
+
| `PATCH` | `/{id}` | Update record by ID |
|
|
946
|
+
| `PATCH` | `/` | Bulk update matching records |
|
|
947
|
+
| `DELETE` | `/{id}` | Delete record by ID |
|
|
948
|
+
| `DELETE` | `/` | Bulk delete matching records |
|
|
949
|
+
|
|
950
|
+
Each generated endpoint includes:
|
|
951
|
+
|
|
952
|
+
- OpenAPI schema documentation derived from entity Zod schemas (select, create, update).
|
|
953
|
+
- Conditional count response via `x-request-count` header -- send `x-request-count: false` to get data only without the wrapping `{ count, data }` object.
|
|
954
|
+
- Content-Range header for paginated find results (e.g., `records 0-19/150`).
|
|
955
|
+
- Authentication and authorization middleware from controller-level or route-level config.
|
|
956
|
+
- `X-Response-Count-Data` response header with the count of returned records.
|
|
957
|
+
|
|
958
|
+
#### Customizing Controller Factory Routes
|
|
959
|
+
|
|
960
|
+
Per-route auth configuration priority:
|
|
961
|
+
|
|
962
|
+
1. If a route has `authenticate: { skip: true }` -- no authentication AND no authorization for that route.
|
|
963
|
+
2. If a route has `authenticate: { strategies, mode }` -- uses these, overriding controller defaults.
|
|
964
|
+
3. If a route has `authorize: { skip: true }` -- keeps authentication but skips authorization.
|
|
965
|
+
4. Otherwise -- uses controller-level `authenticate` and `authorize`.
|
|
966
|
+
|
|
967
|
+
You can override request/response schemas per route:
|
|
968
|
+
|
|
969
|
+
```typescript
|
|
970
|
+
routes: {
|
|
971
|
+
create: {
|
|
972
|
+
request: { body: CustomCreateSchema }, // Custom request body
|
|
973
|
+
response: { schema: CustomResponseSchema }, // Custom response schema
|
|
974
|
+
},
|
|
975
|
+
find: {
|
|
976
|
+
request: { query: CustomFilterQuerySchema }, // Custom query params
|
|
977
|
+
response: { headers: { 'X-Total': { description: 'Total count', schema: { type: 'string' } } } },
|
|
978
|
+
},
|
|
979
|
+
}
|
|
980
|
+
```
|
|
981
|
+
|
|
982
|
+
---
|
|
983
|
+
|
|
984
|
+
## Repositories
|
|
985
|
+
|
|
986
|
+
### Hierarchy
|
|
987
|
+
|
|
988
|
+
```
|
|
989
|
+
AbstractRepository
|
|
990
|
+
extends DefaultFilterMixin(FieldsVisibilityMixin(BaseHelper))
|
|
991
|
+
|
|
|
992
|
+
+-- ReadableRepository (read operations only -- write operations throw errors)
|
|
993
|
+
| |
|
|
994
|
+
| +-- PersistableRepository (+ create, update, delete operations)
|
|
995
|
+
| |
|
|
996
|
+
| +-- DefaultCRUDRepository (alias -- identical to PersistableRepository)
|
|
997
|
+
```
|
|
998
|
+
|
|
999
|
+
`PersistableRepository` is the recommended base class for most use cases. `DefaultCRUDRepository` is a convenience alias. Use `ReadableRepository` when you need a repository that should only read data (e.g., reporting views, read replicas).
|
|
1000
|
+
|
|
1001
|
+
### Defining a Repository
|
|
1002
|
+
|
|
1003
|
+
```typescript
|
|
1004
|
+
// Zero boilerplate -- DataSource auto-injected from @repository metadata
|
|
1005
|
+
@repository({ model: User, dataSource: PostgresDataSource })
|
|
1006
|
+
export class UserRepository extends PersistableRepository<typeof User.schema> {
|
|
1007
|
+
// No constructor needed!
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// Or with explicit @inject for more control
|
|
1011
|
+
@repository({ model: User, dataSource: PostgresDataSource })
|
|
1012
|
+
export class UserRepository extends PersistableRepository<typeof User.schema> {
|
|
1013
|
+
constructor(
|
|
1014
|
+
@inject({ key: 'datasources.PostgresDataSource' }) dataSource: PostgresDataSource,
|
|
1015
|
+
) {
|
|
1016
|
+
super(dataSource);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// Custom methods
|
|
1020
|
+
async findByEmail(email: string) {
|
|
1021
|
+
return this.findOne({ filter: { where: { email } } });
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
async findActiveUsers() {
|
|
1025
|
+
return this.find({
|
|
1026
|
+
filter: {
|
|
1027
|
+
where: { status: 'active' },
|
|
1028
|
+
order: ['createdAt DESC'],
|
|
1029
|
+
},
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
```
|
|
1034
|
+
|
|
1035
|
+
**Important:** Both `model` AND `dataSource` are required in `@repository` for schema auto-discovery. Without both, the model will not be registered in the datasource schema and relational queries will fail.
|
|
1036
|
+
|
|
1037
|
+
### Read Operations
|
|
1038
|
+
|
|
1039
|
+
#### `count()` -- Count Records
|
|
1040
|
+
|
|
1041
|
+
```typescript
|
|
1042
|
+
// Simple count
|
|
1043
|
+
const { count } = await repo.count({ where: { status: 'active' } });
|
|
1044
|
+
|
|
1045
|
+
// Count with complex conditions
|
|
1046
|
+
const { count } = await repo.count({
|
|
1047
|
+
where: {
|
|
1048
|
+
and: [
|
|
1049
|
+
{ role: { inq: ['admin', 'moderator'] } },
|
|
1050
|
+
{ createdAt: { gte: new Date('2024-01-01') } },
|
|
1051
|
+
{ or: [{ isVerified: true }, { score: { gt: 100 } }] },
|
|
1052
|
+
],
|
|
1053
|
+
},
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
// Count within a transaction
|
|
1057
|
+
const { count } = await repo.count({
|
|
1058
|
+
where: { status: 'pending' },
|
|
1059
|
+
options: { transaction: tx },
|
|
1060
|
+
});
|
|
1061
|
+
```
|
|
1062
|
+
|
|
1063
|
+
#### `existsWith()` -- Check Existence
|
|
1064
|
+
|
|
1065
|
+
```typescript
|
|
1066
|
+
const emailTaken = await repo.existsWith({
|
|
1067
|
+
where: { email: 'john@example.com' },
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
if (emailTaken) {
|
|
1071
|
+
throw new Error('Email already in use');
|
|
1072
|
+
}
|
|
1073
|
+
```
|
|
1074
|
+
|
|
1075
|
+
#### `find()` -- Find All Records
|
|
1076
|
+
|
|
1077
|
+
```typescript
|
|
1078
|
+
// Basic find with filter
|
|
1079
|
+
const users = await repo.find({
|
|
1080
|
+
filter: {
|
|
1081
|
+
where: { status: 'active' },
|
|
1082
|
+
fields: ['id', 'name', 'email'],
|
|
1083
|
+
order: ['createdAt DESC'],
|
|
1084
|
+
limit: 20,
|
|
1085
|
+
skip: 0,
|
|
1086
|
+
},
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
// Find with pagination range info
|
|
1090
|
+
const { data, range } = await repo.find({
|
|
1091
|
+
filter: { where: { status: 'active' }, limit: 20, skip: 40 },
|
|
1092
|
+
options: { shouldQueryRange: true },
|
|
1093
|
+
});
|
|
1094
|
+
// data = User[] (the 20 records)
|
|
1095
|
+
// range = { start: 40, end: 59, total: 150 }
|
|
1096
|
+
|
|
1097
|
+
// Find with relation inclusion (uses Query API)
|
|
1098
|
+
const usersWithPosts = await repo.find({
|
|
1099
|
+
filter: {
|
|
1100
|
+
where: { isActive: true },
|
|
1101
|
+
include: [
|
|
1102
|
+
{ relation: 'posts', scope: { where: { isPublished: true }, limit: 5 } },
|
|
1103
|
+
],
|
|
1104
|
+
},
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
// Find all (bypass default filter for admin views)
|
|
1108
|
+
const allUsers = await repo.find({
|
|
1109
|
+
filter: {},
|
|
1110
|
+
options: { shouldSkipDefaultFilter: true },
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
// Find with transaction
|
|
1114
|
+
const users = await repo.find({
|
|
1115
|
+
filter: { where: { batchId: currentBatch } },
|
|
1116
|
+
options: { transaction: tx },
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
// Find with debug logging
|
|
1120
|
+
const users = await repo.find({
|
|
1121
|
+
filter: { where: { status: 'active' } },
|
|
1122
|
+
options: { log: { use: true, level: 'debug' } },
|
|
1123
|
+
});
|
|
1124
|
+
```
|
|
1125
|
+
|
|
1126
|
+
#### `findOne()` vs `findById()` -- Differences
|
|
1127
|
+
|
|
1128
|
+
`findOne()` accepts a full filter with `where`, `fields`, `include`, and `order`. It returns the first matching record:
|
|
1129
|
+
|
|
1130
|
+
```typescript
|
|
1131
|
+
const user = await repo.findOne({
|
|
1132
|
+
filter: {
|
|
1133
|
+
where: { email: 'john@example.com' },
|
|
1134
|
+
fields: ['id', 'name', 'email'],
|
|
1135
|
+
include: [{ relation: 'profile' }],
|
|
1136
|
+
},
|
|
1137
|
+
});
|
|
1138
|
+
// Returns User | null
|
|
1139
|
+
```
|
|
1140
|
+
|
|
1141
|
+
`findById()` is a convenience wrapper around `findOne()` that automatically sets `where: { id }`. It accepts an optional filter **without** the `where` clause:
|
|
1142
|
+
|
|
1143
|
+
```typescript
|
|
1144
|
+
const user = await repo.findById({
|
|
1145
|
+
id: 42,
|
|
1146
|
+
filter: {
|
|
1147
|
+
fields: ['id', 'name', 'email'],
|
|
1148
|
+
include: [{ relation: 'posts' }],
|
|
1149
|
+
},
|
|
1150
|
+
});
|
|
1151
|
+
// Returns User | null
|
|
1152
|
+
// Equivalent to: findOne({ filter: { where: { id: 42 }, fields: [...], include: [...] } })
|
|
1153
|
+
```
|
|
1154
|
+
|
|
1155
|
+
### Write Operations
|
|
1156
|
+
|
|
1157
|
+
#### `create()` -- Create Single Record
|
|
1158
|
+
|
|
1159
|
+
```typescript
|
|
1160
|
+
// Create and return the created record (default: shouldReturn = true)
|
|
1161
|
+
const { count, data } = await repo.create({
|
|
1162
|
+
data: { username: 'john', email: 'john@example.com', role: 'user' },
|
|
1163
|
+
});
|
|
1164
|
+
// count = 1, data = { id: 1, username: 'john', ... }
|
|
1165
|
+
|
|
1166
|
+
// Create without returning data (faster -- skips RETURNING clause)
|
|
1167
|
+
const { count } = await repo.create({
|
|
1168
|
+
data: { username: 'john', email: 'john@example.com' },
|
|
1169
|
+
options: { shouldReturn: false },
|
|
1170
|
+
});
|
|
1171
|
+
// count = 1, data = null
|
|
1172
|
+
|
|
1173
|
+
// Create within a transaction
|
|
1174
|
+
const { data: user } = await repo.create({
|
|
1175
|
+
data: { username: 'john', email: 'john@example.com' },
|
|
1176
|
+
options: { transaction: tx },
|
|
1177
|
+
});
|
|
1178
|
+
```
|
|
1179
|
+
|
|
1180
|
+
#### `createAll()` -- Bulk Create
|
|
1181
|
+
|
|
1182
|
+
```typescript
|
|
1183
|
+
// Bulk create and return all records
|
|
1184
|
+
const { count, data } = await repo.createAll({
|
|
1185
|
+
data: [
|
|
1186
|
+
{ username: 'john', email: 'john@example.com' },
|
|
1187
|
+
{ username: 'jane', email: 'jane@example.com' },
|
|
1188
|
+
{ username: 'bob', email: 'bob@example.com' },
|
|
1189
|
+
],
|
|
1190
|
+
});
|
|
1191
|
+
// count = 3, data = [{ id: 1, ... }, { id: 2, ... }, { id: 3, ... }]
|
|
1192
|
+
|
|
1193
|
+
// Bulk create without returning (faster for large inserts)
|
|
1194
|
+
const { count } = await repo.createAll({
|
|
1195
|
+
data: largeDataArray,
|
|
1196
|
+
options: { shouldReturn: false },
|
|
1197
|
+
});
|
|
1198
|
+
```
|
|
1199
|
+
|
|
1200
|
+
#### `updateById()` -- Update Single Record
|
|
1201
|
+
|
|
1202
|
+
```typescript
|
|
1203
|
+
// Update by ID and return the updated record
|
|
1204
|
+
const { count, data } = await repo.updateById({
|
|
1205
|
+
id: 42,
|
|
1206
|
+
data: { email: 'new@example.com', status: 'verified' },
|
|
1207
|
+
});
|
|
1208
|
+
// count = 1, data = { id: 42, email: 'new@example.com', ... }
|
|
1209
|
+
|
|
1210
|
+
// Update JSON fields using dot notation
|
|
1211
|
+
const { data } = await repo.updateById({
|
|
1212
|
+
id: 42,
|
|
1213
|
+
data: {
|
|
1214
|
+
'metadata.theme': 'dark',
|
|
1215
|
+
'metadata.notifications.email': false,
|
|
1216
|
+
},
|
|
1217
|
+
});
|
|
1218
|
+
```
|
|
1219
|
+
|
|
1220
|
+
#### `updateAll()` / `updateBy()` -- Bulk Update
|
|
1221
|
+
|
|
1222
|
+
```typescript
|
|
1223
|
+
// Update all matching records
|
|
1224
|
+
const { count, data } = await repo.updateAll({
|
|
1225
|
+
data: { status: 'inactive' },
|
|
1226
|
+
where: { lastLoginAt: { lt: new Date('2024-01-01') } },
|
|
1227
|
+
});
|
|
1228
|
+
// count = 25, data = [...25 updated records...]
|
|
1229
|
+
|
|
1230
|
+
// updateBy is an alias for updateAll
|
|
1231
|
+
const { count } = await repo.updateBy({
|
|
1232
|
+
data: { isNotified: true },
|
|
1233
|
+
where: { role: 'subscriber' },
|
|
1234
|
+
options: { shouldReturn: false },
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
// SAFETY: Empty where throws an error to prevent accidental mass updates
|
|
1238
|
+
// Use force: true to explicitly allow it
|
|
1239
|
+
const { count } = await repo.updateAll({
|
|
1240
|
+
data: { version: 2 },
|
|
1241
|
+
where: {},
|
|
1242
|
+
options: { force: true },
|
|
1243
|
+
});
|
|
1244
|
+
```
|
|
1245
|
+
|
|
1246
|
+
### Delete Operations
|
|
1247
|
+
|
|
1248
|
+
```typescript
|
|
1249
|
+
// Delete by ID (returns deleted record)
|
|
1250
|
+
const { count, data } = await repo.deleteById({ id: 42 });
|
|
1251
|
+
// count = 1, data = { id: 42, username: 'john', ... }
|
|
1252
|
+
|
|
1253
|
+
// Delete all matching records
|
|
1254
|
+
const { count, data } = await repo.deleteAll({
|
|
1255
|
+
where: { status: 'inactive' },
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
// deleteBy is an alias for deleteAll
|
|
1259
|
+
const { count } = await repo.deleteBy({
|
|
1260
|
+
where: { expiresAt: { lt: new Date() } },
|
|
1261
|
+
options: { shouldReturn: false },
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
// SAFETY: Empty where throws an error. Use force: true to allow.
|
|
1265
|
+
const { count } = await repo.deleteAll({
|
|
1266
|
+
where: {},
|
|
1267
|
+
options: { force: true, shouldReturn: false },
|
|
1268
|
+
});
|
|
1269
|
+
```
|
|
1270
|
+
|
|
1271
|
+
### Filter System
|
|
1272
|
+
|
|
1273
|
+
```typescript
|
|
1274
|
+
interface TFilter<T> {
|
|
1275
|
+
where?: TWhere<T>; // Query conditions
|
|
1276
|
+
fields?: TFields; // Column selection
|
|
1277
|
+
include?: TInclusion[]; // Relation loading
|
|
1278
|
+
order?: string[]; // Sorting (e.g., ['createdAt DESC', 'name ASC'])
|
|
1279
|
+
limit?: number; // Max results (default: 10)
|
|
1280
|
+
skip?: number; // Offset
|
|
1281
|
+
offset?: number; // Alias for skip
|
|
1282
|
+
}
|
|
1283
|
+
```
|
|
1284
|
+
|
|
1285
|
+
#### Where Operators -- Complete Reference
|
|
1286
|
+
|
|
1287
|
+
**Comparison operators:**
|
|
1288
|
+
|
|
1289
|
+
| Operator | Description | Example |
|
|
1290
|
+
|----------|-------------|---------|
|
|
1291
|
+
| (equality) | Exact match | `{ status: 'active' }` |
|
|
1292
|
+
| `eq` | Equal | `{ age: { eq: 25 } }` |
|
|
1293
|
+
| `ne` / `neq` | Not equal | `{ role: { neq: 'guest' } }` |
|
|
1294
|
+
| `gt` | Greater than | `{ score: { gt: 90 } }` |
|
|
1295
|
+
| `gte` | Greater than or equal | `{ age: { gte: 18 } }` |
|
|
1296
|
+
| `lt` | Less than | `{ price: { lt: 100 } }` |
|
|
1297
|
+
| `lte` | Less than or equal | `{ priority: { lte: 5 } }` |
|
|
1298
|
+
|
|
1299
|
+
**Pattern matching operators:**
|
|
1300
|
+
|
|
1301
|
+
| Operator | Description | Example |
|
|
1302
|
+
|----------|-------------|---------|
|
|
1303
|
+
| `like` | SQL LIKE (case-sensitive) | `{ name: { like: '%john%' } }` |
|
|
1304
|
+
| `ilike` | Case-insensitive LIKE | `{ email: { ilike: '%@GMAIL.COM' } }` |
|
|
1305
|
+
| `nlike` | NOT LIKE | `{ name: { nlike: '%test%' } }` |
|
|
1306
|
+
| `nilike` | NOT ILIKE | `{ email: { nilike: '%spam%' } }` |
|
|
1307
|
+
| `regexp` | POSIX regex (case-sensitive) | `{ code: { regexp: '^[A-Z]{3}' } }` |
|
|
1308
|
+
| `iregexp` | POSIX regex (case-insensitive) | `{ name: { iregexp: '^john' } }` |
|
|
1309
|
+
|
|
1310
|
+
**Array/set operators:**
|
|
1311
|
+
|
|
1312
|
+
| Operator | Description | Example |
|
|
1313
|
+
|----------|-------------|---------|
|
|
1314
|
+
| `inq` / `in` | IN array | `{ status: { inq: ['active', 'pending'] } }` |
|
|
1315
|
+
| `nin` | NOT IN array | `{ role: { nin: ['banned', 'deleted'] } }` |
|
|
1316
|
+
| `between` | BETWEEN two values | `{ age: { between: [18, 65] } }` |
|
|
1317
|
+
| `notBetween` | NOT BETWEEN | `{ score: { notBetween: [0, 10] } }` |
|
|
1318
|
+
|
|
1319
|
+
**Null check operators:**
|
|
1320
|
+
|
|
1321
|
+
| Operator | Description | Example |
|
|
1322
|
+
|----------|-------------|---------|
|
|
1323
|
+
| `is` | IS NULL (when value is null) | `{ deletedAt: { is: null } }` |
|
|
1324
|
+
| `isn` | IS NOT NULL (when value is null) | `{ email: { isn: null } }` |
|
|
1325
|
+
|
|
1326
|
+
**Logical operators:**
|
|
1327
|
+
|
|
1328
|
+
| Operator | Description | Example |
|
|
1329
|
+
|----------|-------------|---------|
|
|
1330
|
+
| `and` | Logical AND | `{ and: [{ status: 'active' }, { role: 'admin' }] }` |
|
|
1331
|
+
| `or` | Logical OR | `{ or: [{ role: 'admin' }, { role: 'moderator' }] }` |
|
|
1332
|
+
|
|
1333
|
+
**PostgreSQL array column operators (for columns defined as `text[]`, `integer[]`, etc.):**
|
|
1334
|
+
|
|
1335
|
+
| Operator | SQL | Description | Example |
|
|
1336
|
+
|----------|-----|-------------|---------|
|
|
1337
|
+
| `contains` | `@>` | Array contains all elements | `{ tags: { contains: ['urgent', 'bug'] } }` |
|
|
1338
|
+
| `containedBy` | `<@` | Array is contained by | `{ tags: { containedBy: ['a', 'b', 'c'] } }` |
|
|
1339
|
+
| `overlaps` | `&&` | Arrays have common elements | `{ categories: { overlaps: ['tech', 'science'] } }` |
|
|
1340
|
+
|
|
1341
|
+
**JSON path queries (for `json`/`jsonb` columns):**
|
|
1342
|
+
|
|
1343
|
+
```typescript
|
|
1344
|
+
// Query nested JSON fields using dot notation
|
|
1345
|
+
const users = await repo.find({
|
|
1346
|
+
filter: {
|
|
1347
|
+
where: {
|
|
1348
|
+
'metadata.theme': 'dark',
|
|
1349
|
+
'settings.notifications.email': true,
|
|
1350
|
+
'preferences.items[0].enabled': { eq: true },
|
|
1351
|
+
'metadata.score': { gt: 50, lte: 100 },
|
|
1352
|
+
},
|
|
1353
|
+
},
|
|
1354
|
+
});
|
|
1355
|
+
```
|
|
1356
|
+
|
|
1357
|
+
JSON path queries automatically handle numeric casting: when a numeric comparison operator (gt, gte, lt, lte, between) is used with a numeric value, the extracted text is safely cast to `numeric` via a CASE expression.
|
|
1358
|
+
|
|
1359
|
+
**Sorting with JSON paths:**
|
|
1360
|
+
|
|
1361
|
+
```typescript
|
|
1362
|
+
const products = await repo.find({
|
|
1363
|
+
filter: {
|
|
1364
|
+
order: [
|
|
1365
|
+
'createdAt DESC',
|
|
1366
|
+
'metadata.priority DESC',
|
|
1367
|
+
'data.nested.score ASC',
|
|
1368
|
+
],
|
|
1369
|
+
},
|
|
1370
|
+
});
|
|
1371
|
+
```
|
|
1372
|
+
|
|
1373
|
+
#### Field Selection
|
|
1374
|
+
|
|
1375
|
+
```typescript
|
|
1376
|
+
// Array format -- include only these columns
|
|
1377
|
+
const users = await repo.find({
|
|
1378
|
+
filter: { fields: ['id', 'name', 'email'] },
|
|
1379
|
+
});
|
|
1380
|
+
|
|
1381
|
+
// Object format -- include/exclude
|
|
1382
|
+
const users = await repo.find({
|
|
1383
|
+
filter: { fields: { id: true, name: true, password: false } },
|
|
1384
|
+
});
|
|
1385
|
+
```
|
|
1386
|
+
|
|
1387
|
+
#### Relation Inclusion
|
|
1388
|
+
|
|
1389
|
+
```typescript
|
|
1390
|
+
// Simple inclusion
|
|
1391
|
+
const users = await repo.find({
|
|
1392
|
+
filter: {
|
|
1393
|
+
include: [{ relation: 'posts' }],
|
|
1394
|
+
},
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
// With nested filter (scope)
|
|
1398
|
+
const users = await repo.find({
|
|
1399
|
+
filter: {
|
|
1400
|
+
include: [{
|
|
1401
|
+
relation: 'posts',
|
|
1402
|
+
scope: {
|
|
1403
|
+
where: { isPublished: true },
|
|
1404
|
+
limit: 5,
|
|
1405
|
+
order: ['createdAt DESC'],
|
|
1406
|
+
include: [{ relation: 'comments' }], // Nested relations
|
|
1407
|
+
},
|
|
1408
|
+
}],
|
|
1409
|
+
},
|
|
1410
|
+
});
|
|
1411
|
+
|
|
1412
|
+
// Skip default filter on a specific relation
|
|
1413
|
+
const users = await repo.find({
|
|
1414
|
+
filter: {
|
|
1415
|
+
include: [{
|
|
1416
|
+
relation: 'archivedPosts',
|
|
1417
|
+
shouldSkipDefaultFilter: true, // Show soft-deleted posts
|
|
1418
|
+
}],
|
|
1419
|
+
},
|
|
1420
|
+
});
|
|
1421
|
+
```
|
|
1422
|
+
|
|
1423
|
+
Note: Relations are defined on the model via `static relations`. The FilterBuilder resolves relation configurations from the MetadataRegistry and applies hidden property exclusion and default filters to included relations automatically.
|
|
1424
|
+
|
|
1425
|
+
### `shouldQueryRange` -- Range Object
|
|
1426
|
+
|
|
1427
|
+
When `shouldQueryRange: true` is passed to `find()`, the method runs both the data fetch and a count query in parallel, then returns:
|
|
1428
|
+
|
|
1429
|
+
```typescript
|
|
1430
|
+
const result = await repo.find({
|
|
1431
|
+
filter: { where: { status: 'active' }, limit: 10, skip: 20 },
|
|
1432
|
+
options: { shouldQueryRange: true },
|
|
1433
|
+
});
|
|
1434
|
+
|
|
1435
|
+
// result.data = User[] (the 10 records)
|
|
1436
|
+
// result.range = { start: 20, end: 29, total: 150 }
|
|
1437
|
+
```
|
|
1438
|
+
|
|
1439
|
+
This follows the HTTP Content-Range header standard. The ControllerFactory uses this to set `Content-Range: records 20-29/150` headers.
|
|
1440
|
+
|
|
1441
|
+
### `shouldSkipDefaultFilter` -- When and Why
|
|
1442
|
+
|
|
1443
|
+
The `shouldSkipDefaultFilter` option bypasses the model's `defaultFilter`. Common use cases:
|
|
1444
|
+
|
|
1445
|
+
```typescript
|
|
1446
|
+
// Admin panel showing all records (including soft-deleted)
|
|
1447
|
+
const allUsers = await repo.find({
|
|
1448
|
+
filter: {},
|
|
1449
|
+
options: { shouldSkipDefaultFilter: true },
|
|
1450
|
+
});
|
|
1451
|
+
|
|
1452
|
+
// Data migration or cleanup script
|
|
1453
|
+
const deletedUsers = await repo.find({
|
|
1454
|
+
filter: { where: { isDeleted: true } },
|
|
1455
|
+
options: { shouldSkipDefaultFilter: true },
|
|
1456
|
+
});
|
|
1457
|
+
|
|
1458
|
+
// Export all data for backup
|
|
1459
|
+
const everything = await repo.find({
|
|
1460
|
+
filter: {},
|
|
1461
|
+
options: { shouldSkipDefaultFilter: true, shouldQueryRange: true },
|
|
1462
|
+
});
|
|
1463
|
+
```
|
|
1464
|
+
|
|
1465
|
+
### ExtraOptions
|
|
1466
|
+
|
|
1467
|
+
All repository operations accept an `options` parameter:
|
|
1468
|
+
|
|
1469
|
+
```typescript
|
|
1470
|
+
interface IExtraOptions {
|
|
1471
|
+
transaction?: ITransaction; // Use within a transaction
|
|
1472
|
+
shouldReturn?: boolean; // Return data after create/update/delete (default: true)
|
|
1473
|
+
shouldQueryRange?: boolean; // Return { data, range: { total, start, end } }
|
|
1474
|
+
shouldSkipDefaultFilter?: boolean; // Bypass model's default filter
|
|
1475
|
+
log?: { use: boolean; level?: TLogLevel }; // Enable operation logging
|
|
1476
|
+
}
|
|
1477
|
+
```
|
|
1478
|
+
|
|
1479
|
+
### Mixins
|
|
1480
|
+
|
|
1481
|
+
#### FieldsVisibilityMixin
|
|
1482
|
+
|
|
1483
|
+
Automatically excludes properties listed in `@model({ settings: { hiddenProperties } })` from all query results at the SQL level -- not post-processing:
|
|
1484
|
+
|
|
1485
|
+
```typescript
|
|
1486
|
+
@model({
|
|
1487
|
+
settings: { hiddenProperties: ['password', 'secretToken'] },
|
|
1488
|
+
})
|
|
1489
|
+
export class User extends BaseEntity<typeof User.schema> { ... }
|
|
1490
|
+
|
|
1491
|
+
// All repository queries automatically exclude 'password' and 'secretToken'
|
|
1492
|
+
const user = await userRepo.findById({ id: 1 });
|
|
1493
|
+
// user.password === undefined (never selected from DB)
|
|
1494
|
+
|
|
1495
|
+
// Hidden fields are excluded from:
|
|
1496
|
+
// - find() / findOne() / findById() SELECT queries
|
|
1497
|
+
// - create() RETURNING clauses
|
|
1498
|
+
// - updateById() / updateAll() RETURNING clauses
|
|
1499
|
+
// - deleteById() / deleteAll() RETURNING clauses
|
|
1500
|
+
// - Included relation queries (applied recursively)
|
|
1501
|
+
```
|
|
1502
|
+
|
|
1503
|
+
The mixin caches the visible property set for performance. It computes it once from the schema columns minus hidden properties.
|
|
1504
|
+
|
|
1505
|
+
#### DefaultFilterMixin
|
|
1506
|
+
|
|
1507
|
+
Automatically applies a default filter to all queries. Common use case: soft delete:
|
|
1508
|
+
|
|
1509
|
+
```typescript
|
|
1510
|
+
@model({
|
|
1511
|
+
settings: { defaultFilter: { where: { isDeleted: false } } },
|
|
1512
|
+
})
|
|
1513
|
+
export class User extends BaseEntity<typeof User.schema> { ... }
|
|
1514
|
+
|
|
1515
|
+
// All queries automatically add WHERE is_deleted = false
|
|
1516
|
+
const users = await userRepo.find({ filter: {} });
|
|
1517
|
+
|
|
1518
|
+
// The default filter merges with user-provided filters:
|
|
1519
|
+
const activeAdmins = await userRepo.find({
|
|
1520
|
+
filter: { where: { role: 'admin' } },
|
|
1521
|
+
});
|
|
1522
|
+
// SQL: WHERE is_deleted = false AND role = 'admin'
|
|
1523
|
+
|
|
1524
|
+
// Bypass when needed (e.g., admin panel showing all records)
|
|
1525
|
+
const allUsers = await userRepo.find({
|
|
1526
|
+
filter: {},
|
|
1527
|
+
options: { shouldSkipDefaultFilter: true },
|
|
1528
|
+
});
|
|
1529
|
+
```
|
|
1530
|
+
|
|
1531
|
+
Merge strategy: `where` conditions are deep-merged (user values override matching keys); all other filter fields (limit, order, etc.) -- user completely replaces default if provided.
|
|
1532
|
+
|
|
1533
|
+
### Dual Query API
|
|
1534
|
+
|
|
1535
|
+
Repositories use two internal query paths:
|
|
1536
|
+
|
|
1537
|
+
- **Core API** (`connector.select().from()`): 15-20% faster. Used for queries without relation inclusion and without explicit field selection. Builds SQL directly via Drizzle's core select/where/orderBy/limit/offset.
|
|
1538
|
+
- **Query API** (`connector.query.EntityName.findMany()`): Supports `include` for relation loading and field selection via `columns`. Used when the filter contains `include` or `fields`.
|
|
1539
|
+
|
|
1540
|
+
The repository automatically selects the appropriate API based on whether `include` or `fields` are present in the filter via `canUseCoreAPI()`. You do not need to think about this -- it is transparent.
|
|
1541
|
+
|
|
1542
|
+
### UpdateBuilder -- JSON Path Updates
|
|
1543
|
+
|
|
1544
|
+
The `UpdateBuilder` transforms data containing dot-notation JSON path keys into chained `jsonb_set()` calls:
|
|
1545
|
+
|
|
1546
|
+
```typescript
|
|
1547
|
+
// Input data:
|
|
1548
|
+
{ name: 'John', 'metadata.settings.theme': 'dark', 'metadata.version': 2 }
|
|
1549
|
+
|
|
1550
|
+
// Generates SQL:
|
|
1551
|
+
// UPDATE users SET
|
|
1552
|
+
// name = 'John',
|
|
1553
|
+
// metadata = jsonb_set(jsonb_set("metadata", '{settings,theme}', '"dark"'::jsonb, true), '{version}', '2'::jsonb, true)
|
|
1554
|
+
// WHERE id = 42
|
|
1555
|
+
```
|
|
1556
|
+
|
|
1557
|
+
Multiple path updates to the same JSON column are chained into a single expression. The `create_missing` parameter is set to `true`, so intermediate keys are created if they do not exist.
|
|
1558
|
+
|
|
1559
|
+
---
|
|
1560
|
+
|
|
1561
|
+
## Models
|
|
1562
|
+
|
|
1563
|
+
### BaseEntity
|
|
1564
|
+
|
|
1565
|
+
All entities extend `BaseEntity` and define a static `schema` using Drizzle's `pgTable`:
|
|
1566
|
+
|
|
1567
|
+
```typescript
|
|
1568
|
+
import { BaseEntity, model } from '@venizia/ignis';
|
|
1569
|
+
import { pgTable, text, integer, jsonb, boolean } from 'drizzle-orm/pg-core';
|
|
1570
|
+
|
|
1571
|
+
@model({
|
|
1572
|
+
type: 'entity',
|
|
1573
|
+
settings: {
|
|
1574
|
+
hiddenProperties: ['password', 'secretToken'],
|
|
1575
|
+
defaultFilter: { where: { isDeleted: false } },
|
|
1576
|
+
},
|
|
1577
|
+
})
|
|
1578
|
+
export class User extends BaseEntity<typeof User.schema> {
|
|
1579
|
+
static override schema = pgTable('User', {
|
|
1580
|
+
...generateIdColumnDefs({ id: { dataType: 'string' } }),
|
|
1581
|
+
...generateTzColumnDefs({
|
|
1582
|
+
deleted: { enable: true, columnName: 'deleted_at', withTimezone: true },
|
|
1583
|
+
}),
|
|
1584
|
+
...generateUserAuditColumnDefs(),
|
|
1585
|
+
username: text('username').notNull().unique(),
|
|
1586
|
+
email: text('email').notNull().unique(),
|
|
1587
|
+
password: text('password'),
|
|
1588
|
+
secretToken: text('secret_token'),
|
|
1589
|
+
role: text('role').default('user'),
|
|
1590
|
+
isDeleted: boolean('is_deleted').default(false),
|
|
1591
|
+
metadata: jsonb('metadata').$type<{ theme?: string; score?: number }>(),
|
|
1592
|
+
});
|
|
1593
|
+
|
|
1594
|
+
static override relations = () => [
|
|
1595
|
+
{ name: 'posts', type: 'many', schema: Post.schema, metadata: { fields: [Post.schema.authorId], references: [User.schema.id] } },
|
|
1596
|
+
{ name: 'profile', type: 'one', schema: Profile.schema, metadata: { fields: [Profile.schema.userId], references: [User.schema.id] } },
|
|
1597
|
+
];
|
|
1598
|
+
|
|
1599
|
+
static TABLE_NAME = 'User';
|
|
1600
|
+
}
|
|
1601
|
+
```
|
|
1602
|
+
|
|
1603
|
+
### @model Decorator
|
|
1604
|
+
|
|
1605
|
+
```typescript
|
|
1606
|
+
@model({
|
|
1607
|
+
type: 'entity', // Entity type identifier
|
|
1608
|
+
settings: {
|
|
1609
|
+
hiddenProperties: ['password'], // Excluded from all queries at SQL level
|
|
1610
|
+
defaultFilter: { where: { isDeleted: false } }, // Auto-applied to all queries
|
|
1611
|
+
},
|
|
1612
|
+
})
|
|
1613
|
+
```
|
|
1614
|
+
|
|
1615
|
+
When `@model` is applied, it registers the class with the `MetadataRegistry`, extracting:
|
|
1616
|
+
- The static `schema` (pgTable definition)
|
|
1617
|
+
- The static `relations` (relation configuration array or resolver function)
|
|
1618
|
+
- The model metadata (type, settings)
|
|
1619
|
+
|
|
1620
|
+
### Schema Generation
|
|
1621
|
+
|
|
1622
|
+
`BaseEntity` provides `getSchema()` for Zod schema generation from the Drizzle table using `drizzle-zod`:
|
|
1623
|
+
|
|
1624
|
+
```typescript
|
|
1625
|
+
const entity = new User();
|
|
1626
|
+
|
|
1627
|
+
entity.getSchema({ type: 'select' }); // Zod schema for SELECT results -- all fields as returned from DB
|
|
1628
|
+
entity.getSchema({ type: 'create' }); // Zod schema for INSERT data -- required/optional based on column definitions
|
|
1629
|
+
entity.getSchema({ type: 'update' }); // Zod schema for UPDATE data -- all fields optional (partial)
|
|
1630
|
+
```
|
|
1631
|
+
|
|
1632
|
+
These schemas are used by `ControllerFactory` to auto-generate OpenAPI documentation. The schema factory is lazily initialized and shared across all BaseEntity instances for performance.
|
|
1633
|
+
|
|
1634
|
+
### Static `relations` Definition
|
|
1635
|
+
|
|
1636
|
+
Relations are defined as a static property or function returning an array of `TRelationConfig`:
|
|
1637
|
+
|
|
1638
|
+
```typescript
|
|
1639
|
+
static override relations = () => [
|
|
1640
|
+
{
|
|
1641
|
+
name: 'posts',
|
|
1642
|
+
type: 'many', // RelationTypes.MANY
|
|
1643
|
+
schema: Post.schema,
|
|
1644
|
+
metadata: {
|
|
1645
|
+
fields: [Post.schema.authorId],
|
|
1646
|
+
references: [User.schema.id],
|
|
1647
|
+
},
|
|
1648
|
+
},
|
|
1649
|
+
{
|
|
1650
|
+
name: 'profile',
|
|
1651
|
+
type: 'one', // RelationTypes.ONE
|
|
1652
|
+
schema: Profile.schema,
|
|
1653
|
+
metadata: {
|
|
1654
|
+
fields: [Profile.schema.userId],
|
|
1655
|
+
references: [User.schema.id],
|
|
1656
|
+
},
|
|
1657
|
+
},
|
|
1658
|
+
];
|
|
1659
|
+
```
|
|
1660
|
+
|
|
1661
|
+
These relations are used by the `FilterBuilder` when processing `include` in filters, and by the DataSource's `discoverSchema()` to build Drizzle relation definitions.
|
|
1662
|
+
|
|
1663
|
+
### Enrichers -- Complete Reference
|
|
1664
|
+
|
|
1665
|
+
Column definition helpers that add common patterns to your table schemas.
|
|
1666
|
+
|
|
1667
|
+
#### `generateIdColumnDefs()` -- ID Column
|
|
1668
|
+
|
|
1669
|
+
```typescript
|
|
1670
|
+
// Auto-incrementing integer ID (default)
|
|
1671
|
+
...generateIdColumnDefs()
|
|
1672
|
+
// Column: id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY
|
|
1673
|
+
|
|
1674
|
+
// Auto-incrementing integer ID (explicit)
|
|
1675
|
+
...generateIdColumnDefs({ id: { dataType: 'number' } })
|
|
1676
|
+
|
|
1677
|
+
// UUID string ID (uses crypto.randomUUID() by default)
|
|
1678
|
+
...generateIdColumnDefs({ id: { dataType: 'string' } })
|
|
1679
|
+
|
|
1680
|
+
// Custom string ID generator
|
|
1681
|
+
...generateIdColumnDefs({
|
|
1682
|
+
id: { dataType: 'string', generator: () => mySnowflakeGenerator() },
|
|
1683
|
+
})
|
|
1684
|
+
|
|
1685
|
+
// BigInt ID (as JavaScript number)
|
|
1686
|
+
...generateIdColumnDefs({ id: { dataType: 'big-number', numberMode: 'number' } })
|
|
1687
|
+
|
|
1688
|
+
// BigInt ID (as JavaScript bigint)
|
|
1689
|
+
...generateIdColumnDefs({ id: { dataType: 'big-number', numberMode: 'bigint' } })
|
|
1690
|
+
|
|
1691
|
+
// With custom sequence options
|
|
1692
|
+
...generateIdColumnDefs({
|
|
1693
|
+
id: { dataType: 'number', sequenceOptions: { startWith: 1000, increment: 1 } },
|
|
1694
|
+
})
|
|
1695
|
+
```
|
|
1696
|
+
|
|
1697
|
+
#### `generateTzColumnDefs()` -- Timestamps
|
|
1698
|
+
|
|
1699
|
+
```typescript
|
|
1700
|
+
// Default: createdAt + modifiedAt (with timezone, defaultNow)
|
|
1701
|
+
...generateTzColumnDefs()
|
|
1702
|
+
// Columns: created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
|
|
1703
|
+
// modified_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL (auto-updates via $onUpdate)
|
|
1704
|
+
|
|
1705
|
+
// With deletedAt for soft delete
|
|
1706
|
+
...generateTzColumnDefs({
|
|
1707
|
+
deleted: { enable: true, columnName: 'deleted_at', withTimezone: true },
|
|
1708
|
+
})
|
|
1709
|
+
// Adds: deleted_at TIMESTAMP WITH TIME ZONE (nullable)
|
|
1710
|
+
|
|
1711
|
+
// Disable modifiedAt
|
|
1712
|
+
...generateTzColumnDefs({
|
|
1713
|
+
modified: { enable: false },
|
|
1714
|
+
})
|
|
1715
|
+
// Only creates: created_at
|
|
1716
|
+
|
|
1717
|
+
// Custom column names
|
|
1718
|
+
...generateTzColumnDefs({
|
|
1719
|
+
created: { columnName: 'date_created', withTimezone: false },
|
|
1720
|
+
modified: { enable: true, columnName: 'date_modified', withTimezone: false },
|
|
1721
|
+
})
|
|
1722
|
+
```
|
|
1723
|
+
|
|
1724
|
+
#### `generateUserAuditColumnDefs()` -- Audit Trail
|
|
1725
|
+
|
|
1726
|
+
Automatically populates `createdBy` and `modifiedBy` from the authenticated user in the request context:
|
|
1727
|
+
|
|
1728
|
+
```typescript
|
|
1729
|
+
// Default: integer columns
|
|
1730
|
+
...generateUserAuditColumnDefs()
|
|
1731
|
+
// Columns: created_by integer, modified_by integer
|
|
1732
|
+
// Auto-populated from context.get('audit.user.id') on create/update
|
|
1733
|
+
|
|
1734
|
+
// String IDs
|
|
1735
|
+
...generateUserAuditColumnDefs({
|
|
1736
|
+
created: { dataType: 'string', columnName: 'created_by', allowAnonymous: true },
|
|
1737
|
+
modified: { dataType: 'string', columnName: 'modified_by', allowAnonymous: true },
|
|
1738
|
+
})
|
|
1739
|
+
```
|
|
1740
|
+
|
|
1741
|
+
When `allowAnonymous: false` (default is `true`), an error is thrown if there is no authenticated user in the request context. This is useful for columns that must always have an audit trail.
|
|
1742
|
+
|
|
1743
|
+
**Important:** `createdBy` is only set on insert (`$default`). `modifiedBy` is set on both insert and update (`$default` + `$onUpdate`).
|
|
1744
|
+
|
|
1745
|
+
#### `generatePrincipalColumnDefs()` -- Polymorphic Relations
|
|
1746
|
+
|
|
1747
|
+
```typescript
|
|
1748
|
+
// Default discriminator name ('principal')
|
|
1749
|
+
...generatePrincipalColumnDefs({ polymorphicIdType: 'number' })
|
|
1750
|
+
// Columns: principal_id integer NOT NULL, principal_type text
|
|
1751
|
+
|
|
1752
|
+
// Custom discriminator
|
|
1753
|
+
...generatePrincipalColumnDefs({
|
|
1754
|
+
discriminator: 'owner',
|
|
1755
|
+
polymorphicIdType: 'string',
|
|
1756
|
+
defaultPolymorphic: 'user',
|
|
1757
|
+
})
|
|
1758
|
+
// Columns: owner_id text NOT NULL, owner_type text DEFAULT 'user'
|
|
1759
|
+
```
|
|
1760
|
+
|
|
1761
|
+
#### `generateDataTypeColumnDefs()` -- Multi-type Value Columns
|
|
1762
|
+
|
|
1763
|
+
For storing heterogeneous values (useful for key-value stores, settings tables):
|
|
1764
|
+
|
|
1765
|
+
```typescript
|
|
1766
|
+
...generateDataTypeColumnDefs()
|
|
1767
|
+
// Columns:
|
|
1768
|
+
// data_type text -- type discriminator
|
|
1769
|
+
// n_value double precision -- numeric values
|
|
1770
|
+
// t_value text -- text values
|
|
1771
|
+
// b_value bytea -- binary values
|
|
1772
|
+
// j_value jsonb -- JSON values
|
|
1773
|
+
// bo_value boolean -- boolean values
|
|
1774
|
+
|
|
1775
|
+
// With defaults
|
|
1776
|
+
...generateDataTypeColumnDefs({
|
|
1777
|
+
defaultValue: { dataType: 'text', tValue: 'default' },
|
|
1778
|
+
})
|
|
1779
|
+
```
|
|
1780
|
+
|
|
1781
|
+
---
|
|
1782
|
+
|
|
1783
|
+
## DataSources
|
|
1784
|
+
|
|
1785
|
+
### BaseDataSource
|
|
1786
|
+
|
|
1787
|
+
DataSources manage database connections and provide Drizzle connectors:
|
|
1788
|
+
|
|
1789
|
+
```typescript
|
|
1790
|
+
abstract class BaseDataSource<Settings, Schema> extends AbstractDataSource {
|
|
1791
|
+
// Implemented by subclass
|
|
1792
|
+
abstract configure(): ValueOrPromise<void>;
|
|
1793
|
+
abstract getConnectionString(): ValueOrPromise<string>;
|
|
1794
|
+
|
|
1795
|
+
// Auto-discovers schema from @repository bindings
|
|
1796
|
+
getSchema(): Schema;
|
|
1797
|
+
|
|
1798
|
+
// Check if any repositories reference this datasource
|
|
1799
|
+
hasDiscoverableModels(): boolean;
|
|
1800
|
+
|
|
1801
|
+
// Transaction support
|
|
1802
|
+
beginTransaction(opts?: ITransactionOptions): Promise<ITransaction>;
|
|
1803
|
+
}
|
|
1804
|
+
```
|
|
1805
|
+
|
|
1806
|
+
### Complete DataSource Configuration
|
|
1807
|
+
|
|
1808
|
+
```typescript
|
|
1809
|
+
@datasource({ driver: 'node-postgres' })
|
|
1810
|
+
export class PostgresDataSource extends BaseDataSource<IDSConfigs> {
|
|
1811
|
+
constructor() {
|
|
1812
|
+
super({
|
|
1813
|
+
name: PostgresDataSource.name,
|
|
1814
|
+
config: {
|
|
1815
|
+
host: process.env.DB_HOST!,
|
|
1816
|
+
port: +(process.env.DB_PORT ?? 5432),
|
|
1817
|
+
database: process.env.DB_NAME!,
|
|
1818
|
+
user: process.env.DB_USER!,
|
|
1819
|
+
password: process.env.DB_PASSWORD!,
|
|
1820
|
+
// pg Pool options:
|
|
1821
|
+
max: 20, // max pool connections
|
|
1822
|
+
idleTimeoutMillis: 30000, // close idle clients after 30s
|
|
1823
|
+
connectionTimeoutMillis: 5000, // timeout connecting after 5s
|
|
1824
|
+
},
|
|
1825
|
+
});
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
override configure(): ValueOrPromise<void> {
|
|
1829
|
+
const schema = this.getSchema(); // Auto-discovers from repositories
|
|
1830
|
+
this.pool = new Pool(this.settings);
|
|
1831
|
+
this.connector = drizzle({ client: this.pool, schema });
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
override getConnectionString() {
|
|
1835
|
+
const { host, port, user, password, database } = this.settings;
|
|
1836
|
+
return `postgresql://${user}:${password}@${host}:${port}/${database}`;
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
```
|
|
1840
|
+
|
|
1841
|
+
### Schema Auto-Discovery
|
|
1842
|
+
|
|
1843
|
+
DataSources do not need manual schema configuration. When `getSchema()` is called during `configure()`, the DataSource:
|
|
1844
|
+
|
|
1845
|
+
1. Queries the `MetadataRegistry` for all `@repository` bindings that reference this DataSource class.
|
|
1846
|
+
2. For each binding, extracts the model's static `schema` (pgTable) and `relations`.
|
|
1847
|
+
3. Combines them into a single schema object: `{ User: User.schema, Post: Post.schema, ...relations }`.
|
|
1848
|
+
4. Caches the result.
|
|
1849
|
+
|
|
1850
|
+
This means adding a new model + repository automatically makes it available to all queries without touching the DataSource code.
|
|
1851
|
+
|
|
1852
|
+
### Transaction Deep Dive
|
|
1853
|
+
|
|
1854
|
+
```typescript
|
|
1855
|
+
const tx = await dataSource.beginTransaction({
|
|
1856
|
+
isolationLevel: 'READ COMMITTED', // default
|
|
1857
|
+
});
|
|
1858
|
+
|
|
1859
|
+
try {
|
|
1860
|
+
await userRepo.create({
|
|
1861
|
+
data: { username: 'john', email: 'john@example.com' },
|
|
1862
|
+
options: { transaction: tx },
|
|
1863
|
+
});
|
|
1864
|
+
|
|
1865
|
+
await auditRepo.create({
|
|
1866
|
+
data: { action: 'user_created', userId: newUser.id },
|
|
1867
|
+
options: { transaction: tx },
|
|
1868
|
+
});
|
|
1869
|
+
|
|
1870
|
+
await tx.commit();
|
|
1871
|
+
} catch (error) {
|
|
1872
|
+
await tx.rollback();
|
|
1873
|
+
throw error;
|
|
1874
|
+
}
|
|
1875
|
+
```
|
|
1876
|
+
|
|
1877
|
+
**How transactions work internally:**
|
|
1878
|
+
|
|
1879
|
+
1. `beginTransaction()` acquires a `PoolClient` from the `pg` Pool.
|
|
1880
|
+
2. Executes `BEGIN TRANSACTION ISOLATION LEVEL <level>` on that client.
|
|
1881
|
+
3. Creates a new Drizzle connector using that dedicated client.
|
|
1882
|
+
4. Returns an `ITransaction` object with `connector`, `commit()`, `rollback()`, and `isActive`.
|
|
1883
|
+
5. When `{ transaction: tx }` is passed to a repository method, `resolveConnector()` returns the transaction's connector instead of the default DataSource connector.
|
|
1884
|
+
6. `commit()` runs `COMMIT`, sets `isActive = false`, and releases the client back to the pool.
|
|
1885
|
+
7. `rollback()` runs `ROLLBACK`, sets `isActive = false`, and releases the client.
|
|
1886
|
+
8. Attempting to use a committed/rolled-back transaction throws an error.
|
|
1887
|
+
|
|
1888
|
+
#### Isolation Levels
|
|
1889
|
+
|
|
1890
|
+
| Level | Constant | When to Use |
|
|
1891
|
+
|-------|----------|-------------|
|
|
1892
|
+
| `READ COMMITTED` | `IsolationLevels.READ_COMMITTED` | Default. Each statement sees only data committed before it began. Sufficient for most CRUD operations. |
|
|
1893
|
+
| `REPEATABLE READ` | `IsolationLevels.REPEATABLE_READ` | All statements in the transaction see a snapshot from the start. Use for consistent reads across multiple queries (e.g., generating reports). |
|
|
1894
|
+
| `SERIALIZABLE` | `IsolationLevels.SERIALIZABLE` | Strictest. Transactions behave as if they ran sequentially. Use for financial operations or inventory management where absolute consistency is required. May cause serialization failures requiring retry. |
|
|
1895
|
+
|
|
1896
|
+
#### Connection Release
|
|
1897
|
+
|
|
1898
|
+
Connections are always released back to the pool in the `finally` block of both `commit()` and `rollback()`. This means even if the commit or rollback SQL fails, the connection is still released, preventing pool exhaustion.
|
|
1899
|
+
|
|
1900
|
+
---
|
|
1901
|
+
|
|
1902
|
+
## Services
|
|
1903
|
+
|
|
1904
|
+
Services encapsulate business logic and are registered in the DI container:
|
|
1905
|
+
|
|
1906
|
+
```typescript
|
|
1907
|
+
import { BaseService, inject } from '@venizia/ignis';
|
|
1908
|
+
|
|
1909
|
+
export class UserService extends BaseService {
|
|
1910
|
+
constructor(
|
|
1911
|
+
@inject({ key: 'repositories.UserRepository' }) private userRepo: UserRepository,
|
|
1912
|
+
@inject({ key: 'repositories.AuditRepository' }) private auditRepo: AuditRepository,
|
|
1913
|
+
) {
|
|
1914
|
+
super({ scope: UserService.name });
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
async createUser(data: CreateUserInput) {
|
|
1918
|
+
const tx = await this.userRepo.dataSource.beginTransaction();
|
|
1919
|
+
try {
|
|
1920
|
+
const { data: user } = await this.userRepo.create({
|
|
1921
|
+
data,
|
|
1922
|
+
options: { transaction: tx },
|
|
1923
|
+
});
|
|
1924
|
+
|
|
1925
|
+
await this.auditRepo.create({
|
|
1926
|
+
data: { action: 'user_created', userId: user.id },
|
|
1927
|
+
options: { transaction: tx },
|
|
1928
|
+
});
|
|
1929
|
+
|
|
1930
|
+
await tx.commit();
|
|
1931
|
+
return user;
|
|
1932
|
+
} catch (error) {
|
|
1933
|
+
await tx.rollback();
|
|
1934
|
+
throw error;
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
async deactivateInactiveUsers() {
|
|
1939
|
+
const cutoff = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); // 90 days
|
|
1940
|
+
const { count } = await this.userRepo.updateBy({
|
|
1941
|
+
data: { status: 'inactive' },
|
|
1942
|
+
where: { lastLoginAt: { lt: cutoff }, status: 'active' },
|
|
1943
|
+
});
|
|
1944
|
+
this.logger.info('Deactivated %d inactive users', count);
|
|
1945
|
+
return { count };
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
```
|
|
1949
|
+
|
|
1950
|
+
Register in the application:
|
|
1951
|
+
|
|
1952
|
+
```typescript
|
|
1953
|
+
this.service(UserService);
|
|
1954
|
+
```
|
|
1955
|
+
|
|
1956
|
+
`BaseService` extends `BaseHelper`, providing a scoped logger instance (`this.logger`).
|
|
1957
|
+
|
|
1958
|
+
---
|
|
1959
|
+
|
|
1960
|
+
## Components
|
|
1961
|
+
|
|
1962
|
+
Components are self-contained modules that register controllers, services, bindings, and middleware. They extend `BaseComponent` and participate in the application lifecycle.
|
|
1963
|
+
|
|
1964
|
+
### Built-in Components
|
|
1965
|
+
|
|
1966
|
+
| Component | Import | Description |
|
|
1967
|
+
|-----------|--------|-------------|
|
|
1968
|
+
| **HealthCheckComponent** | `@venizia/ignis` | Health check endpoints (`GET /health`, `/health/live`, `/health/ready`) |
|
|
1969
|
+
| **SwaggerComponent** | `@venizia/ignis` | OpenAPI documentation with Swagger UI or Scalar UI |
|
|
1970
|
+
| **AuthenticateComponent** | `@venizia/ignis` | JWT + Basic authentication strategies, token services, auth middleware |
|
|
1971
|
+
| **AuthorizeComponent** | `@venizia/ignis` | Casbin-based RBAC authorization with enforcers |
|
|
1972
|
+
| **RequestTrackerComponent** | `@venizia/ignis` | `x-request-id` header injection, request body parsing |
|
|
1973
|
+
| **StaticAssetComponent** | `@venizia/ignis` | File upload/download CRUD with MinIO or disk storage |
|
|
1974
|
+
| **MailComponent** | `@venizia/ignis/mail` | Email sending via Nodemailer/Mailgun with Direct/BullMQ/InternalQueue executors |
|
|
1975
|
+
| **SocketIOComponent** | `@venizia/ignis/socket-io` | Socket.IO server with Redis adapter for horizontal scaling |
|
|
1976
|
+
| **WebSocketComponent** | `@venizia/ignis` | Native WebSocket support (Bun runtime) |
|
|
1977
|
+
|
|
1978
|
+
### Health Check Component
|
|
1979
|
+
|
|
1980
|
+
```typescript
|
|
1981
|
+
import { HealthCheckComponent, HealthCheckBindingKeys, IHealthCheckOptions } from '@venizia/ignis';
|
|
1982
|
+
|
|
1983
|
+
// Optional: customize path (default: /health)
|
|
1984
|
+
this.bind<IHealthCheckOptions>({ key: HealthCheckBindingKeys.HEALTH_CHECK_OPTIONS }).toValue({
|
|
1985
|
+
restOptions: { path: '/health-check' },
|
|
1986
|
+
});
|
|
1987
|
+
this.component(HealthCheckComponent);
|
|
1988
|
+
```
|
|
1989
|
+
|
|
1990
|
+
**Endpoints:**
|
|
1991
|
+
|
|
1992
|
+
| Endpoint | Description |
|
|
1993
|
+
|----------|-------------|
|
|
1994
|
+
| `GET /health` | Basic health check -- returns `{ status: 'ok', uptime, timestamp }` |
|
|
1995
|
+
| `GET /health/live` | Liveness probe -- returns 200 if server is running |
|
|
1996
|
+
| `GET /health/ready` | Readiness probe -- returns 200 if server is ready to accept traffic |
|
|
1997
|
+
|
|
1998
|
+
### Swagger / OpenAPI Component
|
|
1999
|
+
|
|
2000
|
+
```typescript
|
|
2001
|
+
import { SwaggerComponent, SwaggerBindingKeys, ISwaggerOptions } from '@venizia/ignis';
|
|
2002
|
+
|
|
2003
|
+
this.bind<ISwaggerOptions>({ key: SwaggerBindingKeys.SWAGGER_OPTIONS }).toValue({
|
|
2004
|
+
restOptions: {
|
|
2005
|
+
base: { path: '/doc' },
|
|
2006
|
+
doc: { path: '/openapi.json' },
|
|
2007
|
+
ui: { path: '/explorer', type: 'scalar' }, // 'scalar' or 'swagger'
|
|
2008
|
+
},
|
|
2009
|
+
explorer: { openapi: '3.0.0' },
|
|
2010
|
+
});
|
|
2011
|
+
this.component(SwaggerComponent);
|
|
2012
|
+
```
|
|
2013
|
+
|
|
2014
|
+
The component auto-populates `info` from `getAppInfo()` and registers JWT/Basic security schemes in the OpenAPI registry. When `type: 'scalar'` is used, it serves Scalar UI; when `type: 'swagger'`, it serves Swagger UI.
|
|
2015
|
+
|
|
2016
|
+
**Endpoints:**
|
|
2017
|
+
|
|
2018
|
+
| Endpoint | Description |
|
|
2019
|
+
|----------|-------------|
|
|
2020
|
+
| `GET /doc/openapi.json` | Raw OpenAPI spec in JSON |
|
|
2021
|
+
| `GET /doc/explorer` | Interactive API explorer (Scalar or Swagger UI) |
|
|
2022
|
+
|
|
2023
|
+
### Authentication Component
|
|
2024
|
+
|
|
2025
|
+
Supports JWT and Basic authentication strategies with encrypted JWT payloads:
|
|
2026
|
+
|
|
2027
|
+
```typescript
|
|
2028
|
+
import {
|
|
2029
|
+
AuthenticateComponent, AuthenticateBindingKeys,
|
|
2030
|
+
Authentication, AuthenticationStrategyRegistry,
|
|
2031
|
+
JWTAuthenticationStrategy, BasicAuthenticationStrategy,
|
|
2032
|
+
IJWTTokenServiceOptions, IBasicTokenServiceOptions,
|
|
2033
|
+
TAuthenticationRestOptions,
|
|
2034
|
+
} from '@venizia/ignis';
|
|
2035
|
+
|
|
2036
|
+
// 1. Configure JWT options
|
|
2037
|
+
this.bind<IJWTTokenServiceOptions>({ key: AuthenticateBindingKeys.JWT_OPTIONS }).toValue({
|
|
2038
|
+
jwtSecret: process.env.JWT_SECRET!, // Secret for signing JWTs
|
|
2039
|
+
applicationSecret: process.env.APP_SECRET!, // Secret for AES encrypting payload fields
|
|
2040
|
+
headerAlgorithm: 'HS256', // JWT signing algorithm (default: HS256)
|
|
2041
|
+
aesAlgorithm: 'aes-256-cbc', // Payload encryption algorithm (default: aes-256-cbc)
|
|
2042
|
+
getTokenExpiresFn: () => 86400, // Token expiration in seconds (24 hours)
|
|
2043
|
+
});
|
|
2044
|
+
|
|
2045
|
+
// 2. Configure Basic auth options (optional)
|
|
2046
|
+
this.bind<IBasicTokenServiceOptions>({ key: AuthenticateBindingKeys.BASIC_OPTIONS }).toValue({
|
|
2047
|
+
verifyCredentials: async ({ credentials, context }) => {
|
|
2048
|
+
const user = await userRepo.findOne({
|
|
2049
|
+
filter: { where: { username: credentials.username } },
|
|
2050
|
+
});
|
|
2051
|
+
if (user && await verifyPassword(credentials.password, user.password)) {
|
|
2052
|
+
return { userId: user.id, roles: user.roles };
|
|
2053
|
+
}
|
|
2054
|
+
return null;
|
|
2055
|
+
},
|
|
2056
|
+
});
|
|
2057
|
+
|
|
2058
|
+
// 3. Optionally enable auth controller (sign-in, sign-up, change-password)
|
|
2059
|
+
this.bind<TAuthenticationRestOptions>({ key: AuthenticateBindingKeys.REST_OPTIONS }).toValue({
|
|
2060
|
+
useAuthController: true,
|
|
2061
|
+
controllerOpts: {
|
|
2062
|
+
restPath: '/auth',
|
|
2063
|
+
payload: {
|
|
2064
|
+
signIn: { request: { schema: SignInSchema }, response: { schema: TokenSchema } },
|
|
2065
|
+
signUp: { request: { schema: SignUpSchema }, response: { schema: UserSchema } },
|
|
2066
|
+
},
|
|
2067
|
+
},
|
|
2068
|
+
});
|
|
2069
|
+
|
|
2070
|
+
// 4. Register the component
|
|
2071
|
+
this.component(AuthenticateComponent);
|
|
2072
|
+
|
|
2073
|
+
// 5. Register strategies
|
|
2074
|
+
AuthenticationStrategyRegistry.getInstance().register({
|
|
2075
|
+
container: this,
|
|
2076
|
+
strategies: [
|
|
2077
|
+
{ name: Authentication.STRATEGY_JWT, strategy: JWTAuthenticationStrategy },
|
|
2078
|
+
{ name: Authentication.STRATEGY_BASIC, strategy: BasicAuthenticationStrategy },
|
|
2079
|
+
],
|
|
2080
|
+
});
|
|
2081
|
+
```
|
|
2082
|
+
|
|
2083
|
+
#### JWT Token Service API
|
|
2084
|
+
|
|
2085
|
+
```typescript
|
|
2086
|
+
// Generate a token
|
|
2087
|
+
const token = await jwtTokenService.generate({
|
|
2088
|
+
payload: {
|
|
2089
|
+
userId: user.id,
|
|
2090
|
+
roles: [{ id: 1, identifier: 'admin', priority: 1 }],
|
|
2091
|
+
email: user.email,
|
|
2092
|
+
},
|
|
2093
|
+
});
|
|
2094
|
+
|
|
2095
|
+
// Verify a token
|
|
2096
|
+
const payload = await jwtTokenService.verify({
|
|
2097
|
+
type: 'Bearer',
|
|
2098
|
+
token: 'eyJhbGciOiJ...',
|
|
2099
|
+
});
|
|
2100
|
+
// payload = { userId: '...', roles: [...], email: '...' }
|
|
2101
|
+
```
|
|
2102
|
+
|
|
2103
|
+
JWT payloads are AES-encrypted: all non-standard JWT fields (userId, roles, email, etc.) are encrypted with the `applicationSecret` before signing. This prevents payload inspection without the application secret.
|
|
2104
|
+
|
|
2105
|
+
#### Getting Current User from Context
|
|
2106
|
+
|
|
2107
|
+
After authentication middleware runs, the current user is available via context variables:
|
|
2108
|
+
|
|
2109
|
+
```typescript
|
|
2110
|
+
@get({
|
|
2111
|
+
configs: {
|
|
2112
|
+
path: '/profile',
|
|
2113
|
+
authenticate: { strategies: ['jwt'], mode: 'any' },
|
|
2114
|
+
responses: jsonResponse({ schema: UserProfileSchema }),
|
|
2115
|
+
},
|
|
2116
|
+
})
|
|
2117
|
+
async getProfile(context: TRouteContext) {
|
|
2118
|
+
const user = context.get('auth.current.user');
|
|
2119
|
+
// user = { userId: '...', roles: [...], ... }
|
|
2120
|
+
|
|
2121
|
+
const auditId = context.get('audit.user.id');
|
|
2122
|
+
// auditId = user.userId (set automatically for UserAuditEnricher)
|
|
2123
|
+
|
|
2124
|
+
// ...
|
|
2125
|
+
}
|
|
2126
|
+
```
|
|
2127
|
+
|
|
2128
|
+
#### Authentication Modes
|
|
2129
|
+
|
|
2130
|
+
| Mode | Behavior |
|
|
2131
|
+
|------|----------|
|
|
2132
|
+
| `any` (default) | At least one strategy must succeed. Tries each strategy in order; first success wins. |
|
|
2133
|
+
| `all` | All specified strategies must succeed. All are tried; all must pass. |
|
|
2134
|
+
|
|
2135
|
+
#### Route-Level vs Controller-Level Authentication
|
|
2136
|
+
|
|
2137
|
+
```typescript
|
|
2138
|
+
// Controller-level (via ControllerFactory)
|
|
2139
|
+
const UserController = ControllerFactory.defineCrudController({
|
|
2140
|
+
authenticate: { strategies: ['jwt'] }, // Applied to ALL routes
|
|
2141
|
+
routes: {
|
|
2142
|
+
find: { authenticate: { skip: true } }, // Override: public
|
|
2143
|
+
},
|
|
2144
|
+
});
|
|
2145
|
+
|
|
2146
|
+
// Route-level (via decorators)
|
|
2147
|
+
@controller({ path: '/products' })
|
|
2148
|
+
class ProductController extends BaseController {
|
|
2149
|
+
@get({
|
|
2150
|
+
configs: {
|
|
2151
|
+
path: '/',
|
|
2152
|
+
// No authenticate -- public route
|
|
2153
|
+
responses: jsonResponse({ schema: z.array(ProductSchema) }),
|
|
2154
|
+
},
|
|
2155
|
+
})
|
|
2156
|
+
async list(c: TRouteContext) { /* ... */ }
|
|
2157
|
+
|
|
2158
|
+
@post({
|
|
2159
|
+
configs: {
|
|
2160
|
+
path: '/',
|
|
2161
|
+
authenticate: { strategies: ['jwt'] }, // Protected route
|
|
2162
|
+
responses: jsonResponse({ schema: ProductSchema }),
|
|
2163
|
+
},
|
|
2164
|
+
})
|
|
2165
|
+
async create(c: TRouteContext) { /* ... */ }
|
|
2166
|
+
}
|
|
2167
|
+
```
|
|
2168
|
+
|
|
2169
|
+
### Authorization Component
|
|
2170
|
+
|
|
2171
|
+
Casbin-based RBAC authorization:
|
|
2172
|
+
|
|
2173
|
+
```typescript
|
|
2174
|
+
import {
|
|
2175
|
+
AuthorizeComponent, AuthorizeBindingKeys, IAuthorizeOptions,
|
|
2176
|
+
CasbinAuthorizationEnforcer,
|
|
2177
|
+
} from '@venizia/ignis';
|
|
2178
|
+
|
|
2179
|
+
this.bind<IAuthorizeOptions>({ key: AuthorizeBindingKeys.OPTIONS }).toValue({
|
|
2180
|
+
enforcer: CasbinAuthorizationEnforcer,
|
|
2181
|
+
alwaysAllowRoles: ['superadmin'], // Roles that bypass all authorization
|
|
2182
|
+
casbinOptions: {
|
|
2183
|
+
model: '/path/to/model.conf', // Casbin model file
|
|
2184
|
+
adapter: myAdapter, // e.g., FileAdapter, PostgresAdapter
|
|
2185
|
+
},
|
|
2186
|
+
});
|
|
2187
|
+
this.component(AuthorizeComponent);
|
|
2188
|
+
```
|
|
2189
|
+
|
|
2190
|
+
Use on routes:
|
|
2191
|
+
|
|
2192
|
+
```typescript
|
|
2193
|
+
@get({
|
|
2194
|
+
configs: {
|
|
2195
|
+
path: '/admin/users',
|
|
2196
|
+
authenticate: { strategies: ['jwt'] },
|
|
2197
|
+
authorize: { action: 'read', resource: 'User' },
|
|
2198
|
+
responses: jsonResponse({ schema: z.array(UserSchema) }),
|
|
2199
|
+
},
|
|
2200
|
+
})
|
|
2201
|
+
async listUsers(context: TRouteContext) { /* ... */ }
|
|
2202
|
+
|
|
2203
|
+
// Multiple authorization specs (all must pass)
|
|
2204
|
+
@del({
|
|
2205
|
+
configs: {
|
|
2206
|
+
path: '/admin/users/{id}',
|
|
2207
|
+
authenticate: { strategies: ['jwt'] },
|
|
2208
|
+
authorize: [
|
|
2209
|
+
{ action: 'delete', resource: 'User' },
|
|
2210
|
+
{ action: 'manage', resource: 'Admin' },
|
|
2211
|
+
],
|
|
2212
|
+
// ...
|
|
2213
|
+
},
|
|
2214
|
+
})
|
|
2215
|
+
async deleteUser(context: TRouteContext) { /* ... */ }
|
|
2216
|
+
```
|
|
2217
|
+
|
|
2218
|
+
### Static Asset Component
|
|
2219
|
+
|
|
2220
|
+
File upload/download with MinIO or disk storage:
|
|
2221
|
+
|
|
2222
|
+
```typescript
|
|
2223
|
+
import {
|
|
2224
|
+
StaticAssetComponent, StaticAssetComponentBindingKeys,
|
|
2225
|
+
StaticAssetStorageTypes, TStaticAssetsComponentOptions,
|
|
2226
|
+
} from '@venizia/ignis';
|
|
2227
|
+
import { MinioHelper, DiskHelper } from '@venizia/ignis-helpers';
|
|
2228
|
+
|
|
2229
|
+
// MinIO backend
|
|
2230
|
+
this.bind<TStaticAssetsComponentOptions>({
|
|
2231
|
+
key: StaticAssetComponentBindingKeys.STATIC_ASSET_COMPONENT_OPTIONS,
|
|
2232
|
+
}).toValue({
|
|
2233
|
+
staticAsset: {
|
|
2234
|
+
controller: { name: 'AssetController', basePath: '/assets' },
|
|
2235
|
+
storage: StaticAssetStorageTypes.MINIO,
|
|
2236
|
+
helper: new MinioHelper({
|
|
2237
|
+
endPoint: 'localhost',
|
|
2238
|
+
port: 9000,
|
|
2239
|
+
accessKey: 'minioadmin',
|
|
2240
|
+
secretKey: 'minioadmin',
|
|
2241
|
+
useSSL: false,
|
|
2242
|
+
}),
|
|
2243
|
+
},
|
|
2244
|
+
});
|
|
2245
|
+
this.component(StaticAssetComponent);
|
|
2246
|
+
|
|
2247
|
+
// Disk backend
|
|
2248
|
+
this.bind<TStaticAssetsComponentOptions>({
|
|
2249
|
+
key: StaticAssetComponentBindingKeys.STATIC_ASSET_COMPONENT_OPTIONS,
|
|
2250
|
+
}).toValue({
|
|
2251
|
+
staticAsset: {
|
|
2252
|
+
controller: { name: 'AssetController', basePath: '/assets' },
|
|
2253
|
+
storage: StaticAssetStorageTypes.DISK,
|
|
2254
|
+
helper: new DiskHelper({ basePath: './uploads' }),
|
|
2255
|
+
},
|
|
2256
|
+
});
|
|
2257
|
+
```
|
|
2258
|
+
|
|
2259
|
+
### Mail Component
|
|
2260
|
+
|
|
2261
|
+
```typescript
|
|
2262
|
+
import { MailComponent } from '@venizia/ignis/mail';
|
|
2263
|
+
```
|
|
2264
|
+
|
|
2265
|
+
Supports:
|
|
2266
|
+
- **Transporters**: Nodemailer (SMTP), Mailgun (API)
|
|
2267
|
+
- **Executors**: Direct (synchronous send), BullMQ (background queue with Redis), InternalQueue (in-memory queue)
|
|
2268
|
+
|
|
2269
|
+
### Socket.IO Component
|
|
2270
|
+
|
|
2271
|
+
```typescript
|
|
2272
|
+
import { SocketIOComponent, SocketIOBindingKeys } from '@venizia/ignis/socket-io';
|
|
2273
|
+
|
|
2274
|
+
this.bind({ key: SocketIOBindingKeys.OPTIONS }).toValue({
|
|
2275
|
+
cors: { origin: '*' },
|
|
2276
|
+
adapter: redisAdapter, // Optional: Redis adapter for scaling
|
|
2277
|
+
});
|
|
2278
|
+
this.component(SocketIOComponent);
|
|
2279
|
+
```
|
|
2280
|
+
|
|
2281
|
+
Provides Socket.IO server integration with:
|
|
2282
|
+
- Bun and Node.js runtime handlers (auto-detected)
|
|
2283
|
+
- Redis adapter support for horizontal scaling across multiple server instances
|
|
2284
|
+
|
|
2285
|
+
---
|
|
2286
|
+
|
|
2287
|
+
## Request Context
|
|
2288
|
+
|
|
2289
|
+
Access the Hono request context from anywhere using `useRequestContext()`:
|
|
2290
|
+
|
|
2291
|
+
```typescript
|
|
2292
|
+
import { useRequestContext } from '@venizia/ignis';
|
|
2293
|
+
|
|
2294
|
+
function getCurrentRequestId(): string | undefined {
|
|
2295
|
+
const context = useRequestContext();
|
|
2296
|
+
return context?.get('requestId');
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
function getCurrentUser(): IAuthUser | undefined {
|
|
2300
|
+
const context = useRequestContext();
|
|
2301
|
+
return context?.get('auth.current.user');
|
|
2302
|
+
}
|
|
2303
|
+
```
|
|
2304
|
+
|
|
2305
|
+
This uses Hono's `contextStorage()` which stores the context in `AsyncLocalStorage`. It is available anywhere within the request lifecycle -- services, repositories, helpers, enrichers, etc.
|
|
2306
|
+
|
|
2307
|
+
**Note:** Requires `asyncContext.enable: true` in application config (the default).
|
|
2308
|
+
|
|
2309
|
+
---
|
|
2310
|
+
|
|
2311
|
+
## Middleware System
|
|
2312
|
+
|
|
2313
|
+
### Registering Custom Middleware
|
|
2314
|
+
|
|
2315
|
+
Add middleware in `setupMiddlewares()` -- these run on every request:
|
|
2316
|
+
|
|
2317
|
+
```typescript
|
|
2318
|
+
setupMiddlewares(): ValueOrPromise<void> {
|
|
2319
|
+
const server = this.getServer();
|
|
2320
|
+
|
|
2321
|
+
// CORS
|
|
2322
|
+
server.use('*', cors({
|
|
2323
|
+
origin: ['https://myapp.com', 'https://admin.myapp.com'],
|
|
2324
|
+
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
|
2325
|
+
allowHeaders: ['Content-Type', 'Authorization'],
|
|
2326
|
+
credentials: true,
|
|
2327
|
+
}));
|
|
2328
|
+
|
|
2329
|
+
// Body size limit
|
|
2330
|
+
server.use('*', bodyLimit({ maxSize: 50 * 1024 * 1024 })); // 50MB
|
|
2331
|
+
|
|
2332
|
+
// Custom logging middleware
|
|
2333
|
+
server.use('*', async (c, next) => {
|
|
2334
|
+
const start = Date.now();
|
|
2335
|
+
await next();
|
|
2336
|
+
const duration = Date.now() - start;
|
|
2337
|
+
console.log(`${c.req.method} ${c.req.path} ${c.res.status} ${duration}ms`);
|
|
2338
|
+
});
|
|
2339
|
+
|
|
2340
|
+
// Rate limiting on specific paths
|
|
2341
|
+
server.use('/api/auth/*', rateLimiter({
|
|
2342
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
2343
|
+
limit: 100,
|
|
2344
|
+
}));
|
|
2345
|
+
}
|
|
2346
|
+
```
|
|
2347
|
+
|
|
2348
|
+
### Default Middleware Stack (registered automatically)
|
|
2349
|
+
|
|
2350
|
+
1. `appErrorHandler` -- Global error handler
|
|
2351
|
+
2. `contextStorage` -- Async context for `useRequestContext()`
|
|
2352
|
+
3. `notFoundHandler` -- Structured 404 responses
|
|
2353
|
+
4. `RequestTrackerComponent` -- Request ID injection + body parsing
|
|
2354
|
+
5. `emojiFavicon` -- Favicon handler
|
|
2355
|
+
|
|
2356
|
+
---
|
|
2357
|
+
|
|
2358
|
+
## Error Handling
|
|
2359
|
+
|
|
2360
|
+
### Error Propagation
|
|
2361
|
+
|
|
2362
|
+
Errors propagate through the layer stack and are caught by the global `appErrorHandler`:
|
|
2363
|
+
|
|
2364
|
+
```
|
|
2365
|
+
Controller throws -> appErrorHandler catches -> JSON error response
|
|
2366
|
+
Service throws -> Controller doesn't catch -> appErrorHandler catches
|
|
2367
|
+
Repository throws -> Service doesn't catch -> Controller doesn't catch -> appErrorHandler
|
|
2368
|
+
```
|
|
2369
|
+
|
|
2370
|
+
### Error Response Format
|
|
2371
|
+
|
|
2372
|
+
```json
|
|
2373
|
+
{
|
|
2374
|
+
"message": "Error description",
|
|
2375
|
+
"statusCode": 500,
|
|
2376
|
+
"requestId": "abc-123-def",
|
|
2377
|
+
"details": {
|
|
2378
|
+
"url": "http://localhost:3000/api/users",
|
|
2379
|
+
"path": "/api/users",
|
|
2380
|
+
"stack": "Error: ...\n at ...",
|
|
2381
|
+
"cause": { "code": "23505", "detail": "Key (email)=(john@example.com) already exists." }
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
```
|
|
2385
|
+
|
|
2386
|
+
In production (`NODE_ENV=production`), `stack` and `cause` are stripped from responses.
|
|
2387
|
+
|
|
2388
|
+
### PostgreSQL Constraint Errors
|
|
2389
|
+
|
|
2390
|
+
The error handler automatically recognizes PostgreSQL constraint violations and returns HTTP 400 instead of 500:
|
|
2391
|
+
|
|
2392
|
+
| Error Code | Message |
|
|
2393
|
+
|------------|---------|
|
|
2394
|
+
| `23505` | Unique constraint violation |
|
|
2395
|
+
| `23503` | Foreign key constraint violation |
|
|
2396
|
+
| `23502` | Not null constraint violation |
|
|
2397
|
+
| `23514` | Check constraint violation |
|
|
2398
|
+
| `23P01` | Exclusion constraint violation |
|
|
2399
|
+
| `22P02` | Invalid text representation |
|
|
2400
|
+
| `22003` | Numeric value out of range |
|
|
2401
|
+
| `22001` | String data too long |
|
|
2402
|
+
|
|
2403
|
+
### Throwing Application Errors
|
|
2404
|
+
|
|
2405
|
+
Use `getError()` from helpers to throw errors with specific status codes:
|
|
2406
|
+
|
|
2407
|
+
```typescript
|
|
2408
|
+
import { getError, HTTP } from '@venizia/ignis-helpers';
|
|
2409
|
+
|
|
2410
|
+
throw getError({
|
|
2411
|
+
statusCode: HTTP.ResultCodes.RS_4.NotFound,
|
|
2412
|
+
message: 'User not found',
|
|
2413
|
+
});
|
|
2414
|
+
|
|
2415
|
+
throw getError({
|
|
2416
|
+
statusCode: HTTP.ResultCodes.RS_4.Forbidden,
|
|
2417
|
+
message: 'Insufficient permissions',
|
|
2418
|
+
});
|
|
2419
|
+
```
|
|
2420
|
+
|
|
2421
|
+
---
|
|
2422
|
+
|
|
2423
|
+
## Decorators Reference
|
|
2424
|
+
|
|
2425
|
+
| Decorator | Target | Parameters | Description |
|
|
2426
|
+
|-----------|--------|------------|-------------|
|
|
2427
|
+
| `@model({ type?, settings? })` | Class | `type`: entity type string; `settings.hiddenProperties`: string[]; `settings.defaultFilter`: TFilter | Register entity model with hidden properties and default filters |
|
|
2428
|
+
| `@datasource({ driver?, autoDiscovery? })` | Class | `driver`: 'node-postgres'; `autoDiscovery`: boolean (default true) | Register datasource with driver configuration |
|
|
2429
|
+
| `@repository({ model, dataSource })` | Class | `model`: entity class; `dataSource`: datasource class | Bind repository to model and datasource; auto-injects datasource at param[0] |
|
|
2430
|
+
| `@controller({ path, authenticate? })` | Class | `path`: base path string; `authenticate`: { strategies, mode } | Register controller with base path and optional default auth |
|
|
2431
|
+
| `@get({ configs })` | Method | Full route config (path, request, responses, authenticate, authorize, middleware) | Define GET route |
|
|
2432
|
+
| `@post({ configs })` | Method | Same as @get | Define POST route |
|
|
2433
|
+
| `@put({ configs })` | Method | Same as @get | Define PUT route |
|
|
2434
|
+
| `@patch({ configs })` | Method | Same as @get | Define PATCH route |
|
|
2435
|
+
| `@del({ configs })` | Method | Same as @get | Define DELETE route |
|
|
2436
|
+
| `@api({ configs })` | Method | Same as @get + `method` field | Define route with explicit HTTP method |
|
|
2437
|
+
| `@inject({ key, isOptional? })` | Constructor param / Property | `key`: binding key string or symbol; `isOptional`: boolean (default false) | Inject dependency from IoC container |
|
|
2438
|
+
| `@injectable({ scope?, tags? })` | Class | `scope`: 'singleton' or 'transient'; `tags`: string[] | Mark class as injectable with scope and tags |
|
|
2439
|
+
|
|
2440
|
+
---
|
|
2441
|
+
|
|
2442
|
+
## Response Helpers
|
|
2443
|
+
|
|
2444
|
+
Utility functions for building OpenAPI-compliant response and request schemas:
|
|
2445
|
+
|
|
2446
|
+
```typescript
|
|
2447
|
+
import { jsonContent, jsonResponse, htmlResponse, idParamsSchema } from '@venizia/ignis';
|
|
2448
|
+
|
|
2449
|
+
// JSON request body
|
|
2450
|
+
jsonContent({
|
|
2451
|
+
schema: z.object({ name: z.string(), email: z.string() }),
|
|
2452
|
+
description: 'User creation payload',
|
|
2453
|
+
});
|
|
2454
|
+
// => { description, content: { 'application/json': { schema } } }
|
|
2455
|
+
|
|
2456
|
+
// JSON response with automatic error fallback
|
|
2457
|
+
jsonResponse({
|
|
2458
|
+
schema: z.object({ id: z.number(), name: z.string() }),
|
|
2459
|
+
description: 'User object',
|
|
2460
|
+
headers: {
|
|
2461
|
+
'x-request-id': { description: 'Request ID', schema: { type: 'string' } },
|
|
2462
|
+
},
|
|
2463
|
+
});
|
|
2464
|
+
// => { 200: { ... }, '4xx | 5xx': { ... ErrorSchema ... } }
|
|
2465
|
+
|
|
2466
|
+
// HTML response
|
|
2467
|
+
htmlResponse({ description: 'Rendered page' });
|
|
2468
|
+
// => { 200: { content: { 'text/html': { schema } } }, '4xx | 5xx': { ... } }
|
|
2469
|
+
|
|
2470
|
+
// Path parameter schema
|
|
2471
|
+
idParamsSchema({ idType: 'number' });
|
|
2472
|
+
// => z.object({ id: z.number() })
|
|
2473
|
+
|
|
2474
|
+
idParamsSchema({ idType: 'string' });
|
|
2475
|
+
// => z.object({ id: z.string() })
|
|
2476
|
+
```
|
|
2477
|
+
|
|
2478
|
+
---
|
|
2479
|
+
|
|
2480
|
+
## Real-World Patterns
|
|
2481
|
+
|
|
2482
|
+
### Complete User CRUD with Auth, Validation, Soft Delete, Pagination
|
|
2483
|
+
|
|
2484
|
+
```typescript
|
|
2485
|
+
// models/user.model.ts
|
|
2486
|
+
@model({
|
|
2487
|
+
type: 'entity',
|
|
2488
|
+
settings: {
|
|
2489
|
+
hiddenProperties: ['password'],
|
|
2490
|
+
defaultFilter: { where: { isDeleted: false } },
|
|
2491
|
+
},
|
|
2492
|
+
})
|
|
2493
|
+
export class User extends BaseEntity<typeof User.schema> {
|
|
2494
|
+
static override schema = pgTable('User', {
|
|
2495
|
+
...generateIdColumnDefs({ id: { dataType: 'string' } }),
|
|
2496
|
+
...generateTzColumnDefs({
|
|
2497
|
+
deleted: { enable: true, columnName: 'deleted_at', withTimezone: true },
|
|
2498
|
+
}),
|
|
2499
|
+
...generateUserAuditColumnDefs({
|
|
2500
|
+
created: { dataType: 'string', columnName: 'created_by', allowAnonymous: true },
|
|
2501
|
+
modified: { dataType: 'string', columnName: 'modified_by', allowAnonymous: true },
|
|
2502
|
+
}),
|
|
2503
|
+
username: text('username').notNull().unique(),
|
|
2504
|
+
email: text('email').notNull().unique(),
|
|
2505
|
+
password: text('password'),
|
|
2506
|
+
role: text('role').default('user').notNull(),
|
|
2507
|
+
isDeleted: boolean('is_deleted').default(false).notNull(),
|
|
2508
|
+
metadata: jsonb('metadata').$type<Record<string, any>>(),
|
|
2509
|
+
});
|
|
2510
|
+
|
|
2511
|
+
static override relations = () => [];
|
|
2512
|
+
static TABLE_NAME = 'User';
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
// repositories/user.repository.ts
|
|
2516
|
+
@repository({ model: User, dataSource: PostgresDataSource })
|
|
2517
|
+
export class UserRepository extends DefaultCRUDRepository<typeof User.schema> {}
|
|
2518
|
+
|
|
2519
|
+
// services/user.service.ts
|
|
2520
|
+
export class UserService extends BaseService {
|
|
2521
|
+
constructor(
|
|
2522
|
+
@inject({ key: 'repositories.UserRepository' }) private userRepo: UserRepository,
|
|
2523
|
+
) {
|
|
2524
|
+
super({ scope: UserService.name });
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
async createUser(data: { username: string; email: string; password: string }) {
|
|
2528
|
+
const exists = await this.userRepo.existsWith({ where: { email: data.email } });
|
|
2529
|
+
if (exists) {
|
|
2530
|
+
throw getError({
|
|
2531
|
+
statusCode: HTTP.ResultCodes.RS_4.Conflict,
|
|
2532
|
+
message: 'Email already in use',
|
|
2533
|
+
});
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
const hashedPassword = await Bun.password.hash(data.password);
|
|
2537
|
+
return this.userRepo.create({
|
|
2538
|
+
data: { ...data, password: hashedPassword },
|
|
2539
|
+
});
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
async softDeleteUser(id: string) {
|
|
2543
|
+
return this.userRepo.updateById({
|
|
2544
|
+
id,
|
|
2545
|
+
data: { isDeleted: true, deletedAt: new Date() },
|
|
2546
|
+
});
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
// controllers/user.controller.ts
|
|
2551
|
+
@controller({ path: '/users' })
|
|
2552
|
+
export class UserController extends BaseController {
|
|
2553
|
+
constructor(
|
|
2554
|
+
@inject({ key: 'services.UserService' }) private userService: UserService,
|
|
2555
|
+
@inject({ key: 'repositories.UserRepository' }) private userRepo: UserRepository,
|
|
2556
|
+
) {
|
|
2557
|
+
super({ scope: UserController.name });
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
override binding() {}
|
|
2561
|
+
|
|
2562
|
+
@get({
|
|
2563
|
+
configs: {
|
|
2564
|
+
path: '/',
|
|
2565
|
+
description: 'List users with pagination',
|
|
2566
|
+
request: {
|
|
2567
|
+
query: z.object({
|
|
2568
|
+
filter: FilterSchema,
|
|
2569
|
+
}),
|
|
2570
|
+
},
|
|
2571
|
+
responses: jsonResponse({
|
|
2572
|
+
schema: z.object({
|
|
2573
|
+
data: z.array(z.object({
|
|
2574
|
+
id: z.string(),
|
|
2575
|
+
username: z.string(),
|
|
2576
|
+
email: z.string(),
|
|
2577
|
+
role: z.string(),
|
|
2578
|
+
})),
|
|
2579
|
+
range: z.object({ start: z.number(), end: z.number(), total: z.number() }),
|
|
2580
|
+
}),
|
|
2581
|
+
}),
|
|
2582
|
+
},
|
|
2583
|
+
})
|
|
2584
|
+
async list(context: TRouteContext) {
|
|
2585
|
+
const { filter = {} } = context.req.valid<{ filter?: any }>('query');
|
|
2586
|
+
const result = await this.userRepo.find({
|
|
2587
|
+
filter: { ...filter, fields: ['id', 'username', 'email', 'role'] },
|
|
2588
|
+
options: { shouldQueryRange: true },
|
|
2589
|
+
});
|
|
2590
|
+
return context.json(result, 200);
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
@post({
|
|
2594
|
+
configs: {
|
|
2595
|
+
path: '/',
|
|
2596
|
+
authenticate: { strategies: ['jwt'] },
|
|
2597
|
+
authorize: { action: 'create', resource: 'User' },
|
|
2598
|
+
request: {
|
|
2599
|
+
body: jsonContent({
|
|
2600
|
+
schema: z.object({
|
|
2601
|
+
username: z.string().min(3).max(50),
|
|
2602
|
+
email: z.string().email(),
|
|
2603
|
+
password: z.string().min(8),
|
|
2604
|
+
}),
|
|
2605
|
+
}),
|
|
2606
|
+
},
|
|
2607
|
+
responses: jsonResponse({
|
|
2608
|
+
schema: z.object({ count: z.number(), data: z.any() }),
|
|
2609
|
+
}),
|
|
2610
|
+
},
|
|
2611
|
+
})
|
|
2612
|
+
async create(context: TRouteContext) {
|
|
2613
|
+
const data = context.req.valid<{ username: string; email: string; password: string }>('json');
|
|
2614
|
+
const result = await this.userService.createUser(data);
|
|
2615
|
+
return context.json(result, 200);
|
|
2616
|
+
}
|
|
2617
|
+
|
|
2618
|
+
@del({
|
|
2619
|
+
configs: {
|
|
2620
|
+
path: '/{id}',
|
|
2621
|
+
authenticate: { strategies: ['jwt'] },
|
|
2622
|
+
authorize: { action: 'delete', resource: 'User' },
|
|
2623
|
+
request: { params: idParamsSchema({ idType: 'string' }) },
|
|
2624
|
+
responses: jsonResponse({ schema: z.object({ count: z.number() }) }),
|
|
2625
|
+
},
|
|
2626
|
+
})
|
|
2627
|
+
async softDelete(context: TRouteContext) {
|
|
2628
|
+
const { id } = context.req.valid<{ id: string }>('param');
|
|
2629
|
+
const result = await this.userService.softDeleteUser(id);
|
|
2630
|
+
return context.json({ count: result.count }, 200);
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
```
|
|
2634
|
+
|
|
2635
|
+
---
|
|
2636
|
+
|
|
2637
|
+
## Testing
|
|
2638
|
+
|
|
2639
|
+
### Testing Repositories
|
|
2640
|
+
|
|
2641
|
+
```typescript
|
|
2642
|
+
import { describe, test, expect } from 'bun:test';
|
|
2643
|
+
|
|
2644
|
+
describe('UserRepository', () => {
|
|
2645
|
+
let repo: UserRepository;
|
|
2646
|
+
let dataSource: PostgresDataSource;
|
|
2647
|
+
|
|
2648
|
+
beforeAll(async () => {
|
|
2649
|
+
dataSource = new PostgresDataSource();
|
|
2650
|
+
await dataSource.configure();
|
|
2651
|
+
repo = new UserRepository(dataSource, { entityClass: User });
|
|
2652
|
+
});
|
|
2653
|
+
|
|
2654
|
+
test('create and find user', async () => {
|
|
2655
|
+
const { data: user } = await repo.create({
|
|
2656
|
+
data: { username: 'test', email: 'test@example.com' },
|
|
2657
|
+
});
|
|
2658
|
+
|
|
2659
|
+
expect(user.id).toBeDefined();
|
|
2660
|
+
expect(user.username).toBe('test');
|
|
2661
|
+
|
|
2662
|
+
const found = await repo.findById({ id: user.id });
|
|
2663
|
+
expect(found).not.toBeNull();
|
|
2664
|
+
expect(found!.email).toBe('test@example.com');
|
|
2665
|
+
});
|
|
2666
|
+
|
|
2667
|
+
test('hidden fields are excluded', async () => {
|
|
2668
|
+
const { data: user } = await repo.create({
|
|
2669
|
+
data: { username: 'secret', email: 'secret@example.com', password: 'hash123' },
|
|
2670
|
+
});
|
|
2671
|
+
|
|
2672
|
+
// password should not be in the returned data
|
|
2673
|
+
expect(user.password).toBeUndefined();
|
|
2674
|
+
});
|
|
2675
|
+
|
|
2676
|
+
test('default filter excludes soft-deleted records', async () => {
|
|
2677
|
+
const { data: user } = await repo.create({
|
|
2678
|
+
data: { username: 'deleted', email: 'deleted@example.com', isDeleted: true },
|
|
2679
|
+
});
|
|
2680
|
+
|
|
2681
|
+
// Default filter: { where: { isDeleted: false } }
|
|
2682
|
+
const found = await repo.findById({ id: user.id });
|
|
2683
|
+
expect(found).toBeNull();
|
|
2684
|
+
|
|
2685
|
+
// Bypass default filter
|
|
2686
|
+
const foundAll = await repo.findById({
|
|
2687
|
+
id: user.id,
|
|
2688
|
+
options: { shouldSkipDefaultFilter: true },
|
|
2689
|
+
});
|
|
2690
|
+
expect(foundAll).not.toBeNull();
|
|
2691
|
+
});
|
|
2692
|
+
});
|
|
2693
|
+
```
|
|
2694
|
+
|
|
2695
|
+
### Testing Controllers
|
|
2696
|
+
|
|
2697
|
+
```typescript
|
|
2698
|
+
import { describe, test, expect } from 'bun:test';
|
|
2699
|
+
|
|
2700
|
+
describe('UserController', () => {
|
|
2701
|
+
let app: Application;
|
|
2702
|
+
|
|
2703
|
+
beforeAll(async () => {
|
|
2704
|
+
app = new Application();
|
|
2705
|
+
await app.initialize();
|
|
2706
|
+
});
|
|
2707
|
+
|
|
2708
|
+
test('GET /api/users returns 200', async () => {
|
|
2709
|
+
const server = app.getServer();
|
|
2710
|
+
const res = await server.fetch(
|
|
2711
|
+
new Request('http://localhost/api/users'),
|
|
2712
|
+
);
|
|
2713
|
+
expect(res.status).toBe(200);
|
|
2714
|
+
const body = await res.json();
|
|
2715
|
+
expect(Array.isArray(body)).toBe(true);
|
|
2716
|
+
});
|
|
2717
|
+
|
|
2718
|
+
test('POST /api/users validates request body', async () => {
|
|
2719
|
+
const server = app.getServer();
|
|
2720
|
+
const res = await server.fetch(
|
|
2721
|
+
new Request('http://localhost/api/users', {
|
|
2722
|
+
method: 'POST',
|
|
2723
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2724
|
+
body: JSON.stringify({ username: '' }), // Invalid: missing email, empty username
|
|
2725
|
+
}),
|
|
2726
|
+
);
|
|
2727
|
+
expect(res.status).toBe(422);
|
|
2728
|
+
const body = await res.json();
|
|
2729
|
+
expect(body.message).toBe('ValidationError');
|
|
2730
|
+
});
|
|
2731
|
+
});
|
|
2732
|
+
```
|
|
2733
|
+
|
|
2734
|
+
---
|
|
2735
|
+
|
|
2736
|
+
## Performance Tips
|
|
2737
|
+
|
|
2738
|
+
1. **Singleton DataSources** -- DataSources are registered with `BindingScopes.SINGLETON` by default. The connection pool is shared across all repository instances. Never create DataSource instances per-request.
|
|
2739
|
+
|
|
2740
|
+
2. **Lazy Entity Resolution** -- Entity instances are resolved from metadata only on first access. This avoids unnecessary construction during application startup.
|
|
2741
|
+
|
|
2742
|
+
3. **Hidden Fields at SQL Level** -- `hiddenProperties` are excluded in the SQL SELECT clause, not filtered post-query. This means sensitive data never leaves the database.
|
|
2743
|
+
|
|
2744
|
+
4. **Core API vs Query API** -- The repository automatically uses the faster Core API (15-20% faster) when your filter does not include relations or explicit field selection. No manual optimization needed.
|
|
2745
|
+
|
|
2746
|
+
5. **Visible Property Caching** -- The `FieldsVisibilityMixin` computes the visible property set once and caches it. Subsequent queries reuse the cached column selection.
|
|
2747
|
+
|
|
2748
|
+
6. **Parallel Count + Data** -- When `shouldQueryRange: true`, the data fetch and count query run in parallel via `Promise.all`, not sequentially.
|
|
2749
|
+
|
|
2750
|
+
7. **Schema Factory Sharing** -- `BaseEntity` uses a lazy singleton for the Drizzle-Zod schema factory, shared across all entity instances. Schema generation does not create redundant factory objects.
|
|
2751
|
+
|
|
2752
|
+
8. **Avoid `shouldReturn: true` for Bulk Inserts** -- When inserting large batches, pass `shouldReturn: false` to skip the `RETURNING` clause, which significantly reduces response payload size and memory usage.
|
|
2753
|
+
|
|
2754
|
+
9. **Use Transactions Wisely** -- Each transaction acquires a dedicated connection from the pool. Long-running transactions hold connections and can starve other requests. Keep transactions short and always release them (commit or rollback) in a try/finally block.
|
|
2755
|
+
|
|
2756
|
+
10. **Column Cache** -- The `FilterBuilder` and `UpdateBuilder` use `getCachedColumns()` to avoid repeatedly parsing table schema metadata. Columns are computed once per table and cached globally.
|
|
2757
|
+
|
|
2758
|
+
---
|
|
2759
|
+
|
|
2760
|
+
## Documentation
|
|
2761
|
+
|
|
2762
|
+
- [Ignis Repository](https://github.com/VENIZIA-AI/ignis)
|
|
2763
|
+
- [Getting Started](https://github.com/VENIZIA-AI/ignis/blob/main/packages/docs/wiki/get-started/index.md)
|
|
2764
|
+
- [Core Concepts](https://github.com/VENIZIA-AI/ignis/blob/main/packages/docs/wiki/get-started/core-concepts/application.md)
|
|
2765
|
+
- [Examples](https://github.com/VENIZIA-AI/ignis/tree/main/examples/vert)
|
|
59
2766
|
|
|
60
|
-
|
|
61
|
-
- [Getting Started](https://github.com/venizia-ai/ignis/blob/main/packages/docs/wiki/get-started/index.md)
|
|
62
|
-
- [Core Concepts](https://github.com/venizia-ai/ignis/blob/main/packages/docs/wiki/get-started/core-concepts/application.md)
|
|
2767
|
+
---
|
|
63
2768
|
|
|
64
2769
|
## License
|
|
65
2770
|
|