@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.
Files changed (124) hide show
  1. package/README.md +2730 -25
  2. package/dist/base/controllers/factory/controller.d.ts +9 -9
  3. package/dist/base/controllers/factory/definition.d.ts +9 -9
  4. package/dist/components/auth/authenticate/common/types.d.ts +2 -8
  5. package/dist/components/auth/authenticate/common/types.d.ts.map +1 -1
  6. package/dist/components/auth/authenticate/component.d.ts +1 -15
  7. package/dist/components/auth/authenticate/component.d.ts.map +1 -1
  8. package/dist/components/auth/authenticate/component.js +25 -49
  9. package/dist/components/auth/authenticate/component.js.map +1 -1
  10. package/dist/components/auth/authenticate/controllers/factory.d.ts.map +1 -1
  11. package/dist/components/auth/authenticate/controllers/factory.js +2 -2
  12. package/dist/components/auth/authenticate/controllers/factory.js.map +1 -1
  13. package/dist/components/auth/authenticate/strategies/basic.strategy.d.ts.map +1 -1
  14. package/dist/components/auth/authenticate/strategies/basic.strategy.js +8 -1
  15. package/dist/components/auth/authenticate/strategies/basic.strategy.js.map +1 -1
  16. package/dist/components/auth/authenticate/strategies/jwt.strategy.d.ts.map +1 -1
  17. package/dist/components/auth/authenticate/strategies/jwt.strategy.js +8 -1
  18. package/dist/components/auth/authenticate/strategies/jwt.strategy.js.map +1 -1
  19. package/dist/components/auth/authorize/adapters/base-filtered.d.ts +73 -0
  20. package/dist/components/auth/authorize/adapters/base-filtered.d.ts.map +1 -0
  21. package/dist/components/auth/authorize/adapters/base-filtered.js +98 -0
  22. package/dist/components/auth/authorize/adapters/base-filtered.js.map +1 -0
  23. package/dist/components/auth/authorize/adapters/drizzle-casbin.d.ts +39 -0
  24. package/dist/components/auth/authorize/adapters/drizzle-casbin.d.ts.map +1 -0
  25. package/dist/components/auth/authorize/adapters/drizzle-casbin.js +100 -0
  26. package/dist/components/auth/authorize/adapters/drizzle-casbin.js.map +1 -0
  27. package/dist/components/auth/authorize/adapters/index.d.ts +3 -0
  28. package/dist/components/auth/authorize/adapters/index.d.ts.map +1 -0
  29. package/dist/components/auth/authorize/adapters/index.js +19 -0
  30. package/dist/components/auth/authorize/adapters/index.js.map +1 -0
  31. package/dist/components/auth/authorize/common/constants.d.ts +37 -4
  32. package/dist/components/auth/authorize/common/constants.d.ts.map +1 -1
  33. package/dist/components/auth/authorize/common/constants.js +65 -5
  34. package/dist/components/auth/authorize/common/constants.js.map +1 -1
  35. package/dist/components/auth/authorize/common/keys.d.ts +1 -2
  36. package/dist/components/auth/authorize/common/keys.d.ts.map +1 -1
  37. package/dist/components/auth/authorize/common/keys.js +3 -2
  38. package/dist/components/auth/authorize/common/keys.js.map +1 -1
  39. package/dist/components/auth/authorize/common/types.d.ts +88 -78
  40. package/dist/components/auth/authorize/common/types.d.ts.map +1 -1
  41. package/dist/components/auth/authorize/component.d.ts +1 -0
  42. package/dist/components/auth/authorize/component.d.ts.map +1 -1
  43. package/dist/components/auth/authorize/component.js +13 -33
  44. package/dist/components/auth/authorize/component.js.map +1 -1
  45. package/dist/components/auth/authorize/enforcers/casbin.enforcer.d.ts +45 -11
  46. package/dist/components/auth/authorize/enforcers/casbin.enforcer.d.ts.map +1 -1
  47. package/dist/components/auth/authorize/enforcers/casbin.enforcer.js +204 -35
  48. package/dist/components/auth/authorize/enforcers/casbin.enforcer.js.map +1 -1
  49. package/dist/components/auth/authorize/enforcers/enforcer-registry.d.ts +11 -6
  50. package/dist/components/auth/authorize/enforcers/enforcer-registry.d.ts.map +1 -1
  51. package/dist/components/auth/authorize/enforcers/enforcer-registry.js +28 -8
  52. package/dist/components/auth/authorize/enforcers/enforcer-registry.js.map +1 -1
  53. package/dist/components/auth/authorize/enforcers/index.d.ts +0 -1
  54. package/dist/components/auth/authorize/enforcers/index.d.ts.map +1 -1
  55. package/dist/components/auth/authorize/enforcers/index.js +0 -1
  56. package/dist/components/auth/authorize/enforcers/index.js.map +1 -1
  57. package/dist/components/auth/authorize/index.d.ts +1 -0
  58. package/dist/components/auth/authorize/index.d.ts.map +1 -1
  59. package/dist/components/auth/authorize/index.js +1 -0
  60. package/dist/components/auth/authorize/index.js.map +1 -1
  61. package/dist/components/auth/authorize/models/abilities/index.d.ts +3 -0
  62. package/dist/components/auth/authorize/models/abilities/index.d.ts.map +1 -0
  63. package/dist/components/auth/authorize/models/abilities/index.js +19 -0
  64. package/dist/components/auth/authorize/models/abilities/index.js.map +1 -0
  65. package/dist/components/auth/authorize/models/abilities/string-action.model.d.ts +14 -0
  66. package/dist/components/auth/authorize/models/abilities/string-action.model.d.ts.map +1 -0
  67. package/dist/components/auth/authorize/models/abilities/string-action.model.js +24 -0
  68. package/dist/components/auth/authorize/models/abilities/string-action.model.js.map +1 -0
  69. package/dist/components/auth/authorize/models/abilities/string-resource.model.d.ts +13 -0
  70. package/dist/components/auth/authorize/models/abilities/string-resource.model.d.ts.map +1 -0
  71. package/dist/components/auth/authorize/models/abilities/string-resource.model.js +20 -0
  72. package/dist/components/auth/authorize/models/abilities/string-resource.model.js.map +1 -0
  73. package/dist/components/auth/authorize/models/index.d.ts +1 -0
  74. package/dist/components/auth/authorize/models/index.d.ts.map +1 -1
  75. package/dist/components/auth/authorize/models/index.js +1 -0
  76. package/dist/components/auth/authorize/models/index.js.map +1 -1
  77. package/dist/components/auth/authorize/providers/authorization.provider.d.ts.map +1 -1
  78. package/dist/components/auth/authorize/providers/authorization.provider.js +44 -38
  79. package/dist/components/auth/authorize/providers/authorization.provider.js.map +1 -1
  80. package/dist/components/auth/base/abstract-auth-registry.d.ts +1 -0
  81. package/dist/components/auth/base/abstract-auth-registry.d.ts.map +1 -1
  82. package/dist/components/auth/base/abstract-auth-registry.js +3 -0
  83. package/dist/components/auth/base/abstract-auth-registry.js.map +1 -1
  84. package/dist/components/auth/context-variables.d.ts +14 -0
  85. package/dist/components/auth/context-variables.d.ts.map +1 -0
  86. package/dist/components/auth/context-variables.js +3 -0
  87. package/dist/components/auth/context-variables.js.map +1 -0
  88. package/dist/components/auth/index.d.ts +1 -0
  89. package/dist/components/auth/index.d.ts.map +1 -1
  90. package/dist/components/auth/index.js +1 -0
  91. package/dist/components/auth/index.js.map +1 -1
  92. package/dist/components/auth/models/entities/index.d.ts +1 -2
  93. package/dist/components/auth/models/entities/index.d.ts.map +1 -1
  94. package/dist/components/auth/models/entities/index.js +1 -2
  95. package/dist/components/auth/models/entities/index.js.map +1 -1
  96. package/dist/components/auth/models/entities/permission.model.d.ts +0 -1
  97. package/dist/components/auth/models/entities/permission.model.d.ts.map +1 -1
  98. package/dist/components/auth/models/entities/permission.model.js +0 -2
  99. package/dist/components/auth/models/entities/permission.model.js.map +1 -1
  100. package/dist/components/auth/models/entities/policy-definition.model.d.ts +24 -0
  101. package/dist/components/auth/models/entities/policy-definition.model.d.ts.map +1 -0
  102. package/dist/components/auth/models/entities/policy-definition.model.js +39 -0
  103. package/dist/components/auth/models/entities/policy-definition.model.js.map +1 -0
  104. package/dist/components/auth/models/entities/role.model.d.ts +3 -1
  105. package/dist/components/auth/models/entities/role.model.d.ts.map +1 -1
  106. package/dist/components/auth/models/entities/role.model.js +4 -1
  107. package/dist/components/auth/models/entities/role.model.js.map +1 -1
  108. package/dist/components/auth/models/entities/user.model.d.ts +4 -2
  109. package/dist/components/auth/models/entities/user.model.d.ts.map +1 -1
  110. package/dist/components/auth/models/entities/user.model.js +5 -3
  111. package/dist/components/auth/models/entities/user.model.js.map +1 -1
  112. package/package.json +97 -71
  113. package/dist/components/auth/authorize/enforcers/default.enforcer.d.ts +0 -37
  114. package/dist/components/auth/authorize/enforcers/default.enforcer.d.ts.map +0 -1
  115. package/dist/components/auth/authorize/enforcers/default.enforcer.js +0 -125
  116. package/dist/components/auth/authorize/enforcers/default.enforcer.js.map +0 -1
  117. package/dist/components/auth/models/entities/permission-mapping.model.d.ts +0 -26
  118. package/dist/components/auth/models/entities/permission-mapping.model.d.ts.map +0 -1
  119. package/dist/components/auth/models/entities/permission-mapping.model.js +0 -33
  120. package/dist/components/auth/models/entities/permission-mapping.model.js.map +0 -1
  121. package/dist/components/auth/models/entities/user-role.model.d.ts +0 -17
  122. package/dist/components/auth/models/entities/user-role.model.d.ts.map +0 -1
  123. package/dist/components/auth/models/entities/user-role.model.js +0 -34
  124. 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
  [![npm version](https://img.shields.io/npm/v/@venizia/ignis.svg)](https://www.npmjs.com/package/@venizia/ignis)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.x-blue.svg)](https://www.typescriptlang.org/)
6
+ [![Hono](https://img.shields.io/badge/Hono-4.x-orange.svg)](https://hono.dev/)
7
+ [![Drizzle ORM](https://img.shields.io/badge/Drizzle_ORM-0.45-green.svg)](https://orm.drizzle.team/)
5
8
 
6
- The core package of the **Ignis Framework** - a TypeScript Server Infrastructure combining enterprise-grade patterns with high performance, built on [Hono](https://hono.dev/).
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
- bun add hono @hono/zod-openapi @scalar/hono-api-reference drizzle-orm drizzle-zod pg jose
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
- ## Quick Example
118
+ ### 2. Define a DataSource
23
119
 
24
120
  ```typescript
25
- import { BaseApplication, BaseController, controller, get, HTTP, jsonContent } from "@venizia/ignis";
26
- import { z } from "@hono/zod-openapi";
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
- @controller({ path: "/hello" })
29
- class HelloController extends BaseController {
134
+ @datasource({ driver: 'node-postgres' })
135
+ export class PostgresDataSource extends BaseDataSource<IDSConfigs> {
30
136
  constructor() {
31
- super({ scope: "HelloController", path: "/hello" });
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
- method: HTTP.Methods.GET,
40
- responses: {
41
- [HTTP.ResultCodes.RS_2.Ok]: jsonContent({
42
- description: "Says hello",
43
- schema: z.object({ message: z.string() }),
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
- sayHello(c: Context) {
49
- return c.json({ message: "Hello from Ignis!" }, HTTP.ResultCodes.RS_2.Ok);
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
- ## About Ignis
233
+ ### 5. Define the Application
55
234
 
56
- 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.
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
- ## Documentation
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
- - [Ignis Repository](https://github.com/venizia-ai/ignis)
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