@venizia/ignis 0.1.1-3 → 0.1.1-5

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 (242) hide show
  1. package/README.md +164 -2428
  2. package/dist/base/applications/abstract.d.ts +3 -6
  3. package/dist/base/applications/abstract.d.ts.map +1 -1
  4. package/dist/base/applications/abstract.js +6 -12
  5. package/dist/base/applications/abstract.js.map +1 -1
  6. package/dist/base/applications/base.d.ts +2 -5
  7. package/dist/base/applications/base.d.ts.map +1 -1
  8. package/dist/base/applications/base.js +4 -7
  9. package/dist/base/applications/base.js.map +1 -1
  10. package/dist/base/controllers/common/types.d.ts +4 -11
  11. package/dist/base/controllers/common/types.d.ts.map +1 -1
  12. package/dist/base/controllers/common/types.js.map +1 -1
  13. package/dist/base/controllers/factory/controller.d.ts +2 -2
  14. package/dist/base/controllers/factory/definition.d.ts +0 -9
  15. package/dist/base/controllers/factory/definition.d.ts.map +1 -1
  16. package/dist/base/controllers/factory/definition.js +2 -5
  17. package/dist/base/controllers/factory/definition.js.map +1 -1
  18. package/dist/base/controllers/grpc/adapter.d.ts +3 -11
  19. package/dist/base/controllers/grpc/adapter.d.ts.map +1 -1
  20. package/dist/base/controllers/grpc/adapter.js +3 -11
  21. package/dist/base/controllers/grpc/adapter.js.map +1 -1
  22. package/dist/base/controllers/rest/abstract.d.ts.map +1 -1
  23. package/dist/base/controllers/rest/abstract.js +3 -5
  24. package/dist/base/controllers/rest/abstract.js.map +1 -1
  25. package/dist/base/datasources/common/types.d.ts +5 -13
  26. package/dist/base/datasources/common/types.d.ts.map +1 -1
  27. package/dist/base/metadata/injectors.d.ts +1 -2
  28. package/dist/base/metadata/injectors.d.ts.map +1 -1
  29. package/dist/base/metadata/injectors.js +1 -5
  30. package/dist/base/metadata/injectors.js.map +1 -1
  31. package/dist/base/metadata/routes/common.d.ts +4 -10
  32. package/dist/base/metadata/routes/common.d.ts.map +1 -1
  33. package/dist/base/metadata/routes/common.js +4 -10
  34. package/dist/base/metadata/routes/common.js.map +1 -1
  35. package/dist/base/middlewares/app-error/app-error.middleware.d.ts +21 -13
  36. package/dist/base/middlewares/app-error/app-error.middleware.d.ts.map +1 -1
  37. package/dist/base/middlewares/app-error/app-error.middleware.js +111 -78
  38. package/dist/base/middlewares/app-error/app-error.middleware.js.map +1 -1
  39. package/dist/base/middlewares/app-error/database.handler.d.ts +3 -5
  40. package/dist/base/middlewares/app-error/database.handler.d.ts.map +1 -1
  41. package/dist/base/middlewares/app-error/database.handler.js +6 -9
  42. package/dist/base/middlewares/app-error/database.handler.js.map +1 -1
  43. package/dist/base/middlewares/app-error/definition.d.ts +15 -21
  44. package/dist/base/middlewares/app-error/definition.d.ts.map +1 -1
  45. package/dist/base/middlewares/app-error/definition.js +17 -22
  46. package/dist/base/middlewares/app-error/definition.js.map +1 -1
  47. package/dist/base/middlewares/app-error/types.d.ts +12 -0
  48. package/dist/base/middlewares/app-error/types.d.ts.map +1 -1
  49. package/dist/base/middlewares/app-error/zod.handler.d.ts +1 -6
  50. package/dist/base/middlewares/app-error/zod.handler.d.ts.map +1 -1
  51. package/dist/base/middlewares/app-error/zod.handler.js +3 -11
  52. package/dist/base/middlewares/app-error/zod.handler.js.map +1 -1
  53. package/dist/base/middlewares/not-found/not-found.middleware.d.ts +2 -2
  54. package/dist/base/middlewares/not-found/not-found.middleware.d.ts.map +1 -1
  55. package/dist/base/middlewares/not-found/not-found.middleware.js.map +1 -1
  56. package/dist/base/models/common/types.d.ts +0 -1
  57. package/dist/base/models/common/types.d.ts.map +1 -1
  58. package/dist/common/error-codes.d.ts.map +1 -1
  59. package/dist/common/error-codes.js +4 -9
  60. package/dist/common/error-codes.js.map +1 -1
  61. package/dist/components/auth/authenticate/controllers/factory.d.ts +2 -2
  62. package/dist/components/auth/authenticate/controllers/jwks/definitions.d.ts +0 -1
  63. package/dist/components/auth/authenticate/controllers/jwks/definitions.d.ts.map +1 -1
  64. package/dist/components/auth/authenticate/services/bearer/jwks/abstract.service.d.ts +2 -5
  65. package/dist/components/auth/authenticate/services/bearer/jwks/abstract.service.d.ts.map +1 -1
  66. package/dist/components/auth/authenticate/services/bearer/jwks/abstract.service.js +2 -5
  67. package/dist/components/auth/authenticate/services/bearer/jwks/abstract.service.js.map +1 -1
  68. package/dist/components/auth/authorize/adapters/base-filtered.d.ts +2 -3
  69. package/dist/components/auth/authorize/adapters/base-filtered.d.ts.map +1 -1
  70. package/dist/components/auth/authorize/adapters/base-filtered.js +2 -3
  71. package/dist/components/auth/authorize/adapters/base-filtered.js.map +1 -1
  72. package/dist/components/auth/authorize/adapters/types.d.ts +5 -7
  73. package/dist/components/auth/authorize/adapters/types.d.ts.map +1 -1
  74. package/dist/components/auth/authorize/common/constants.d.ts +18 -45
  75. package/dist/components/auth/authorize/common/constants.d.ts.map +1 -1
  76. package/dist/components/auth/authorize/common/constants.js +18 -45
  77. package/dist/components/auth/authorize/common/constants.js.map +1 -1
  78. package/dist/components/auth/authorize/common/object-match.d.ts +3 -17
  79. package/dist/components/auth/authorize/common/object-match.d.ts.map +1 -1
  80. package/dist/components/auth/authorize/common/object-match.js +3 -17
  81. package/dist/components/auth/authorize/common/object-match.js.map +1 -1
  82. package/dist/components/auth/authorize/common/permission-builder.d.ts +5 -9
  83. package/dist/components/auth/authorize/common/permission-builder.d.ts.map +1 -1
  84. package/dist/components/auth/authorize/common/permission-builder.js +5 -9
  85. package/dist/components/auth/authorize/common/permission-builder.js.map +1 -1
  86. package/dist/components/auth/authorize/common/policy-builder.d.ts +3 -5
  87. package/dist/components/auth/authorize/common/policy-builder.d.ts.map +1 -1
  88. package/dist/components/auth/authorize/common/policy-builder.js +3 -5
  89. package/dist/components/auth/authorize/common/policy-builder.js.map +1 -1
  90. package/dist/components/auth/authorize/common/resolve-request-domain.d.ts +2 -3
  91. package/dist/components/auth/authorize/common/resolve-request-domain.d.ts.map +1 -1
  92. package/dist/components/auth/authorize/common/resolve-request-domain.js +2 -3
  93. package/dist/components/auth/authorize/common/resolve-request-domain.js.map +1 -1
  94. package/dist/components/auth/authorize/common/types.d.ts +4 -8
  95. package/dist/components/auth/authorize/common/types.d.ts.map +1 -1
  96. package/dist/components/auth/authorize/enforcers/casbin.enforcer.d.ts +8 -16
  97. package/dist/components/auth/authorize/enforcers/casbin.enforcer.d.ts.map +1 -1
  98. package/dist/components/auth/authorize/enforcers/casbin.enforcer.js +17 -36
  99. package/dist/components/auth/authorize/enforcers/casbin.enforcer.js.map +1 -1
  100. package/dist/components/auth/authorize/enforcers/models/rbac-domain.model.d.ts +3 -28
  101. package/dist/components/auth/authorize/enforcers/models/rbac-domain.model.d.ts.map +1 -1
  102. package/dist/components/auth/authorize/enforcers/models/rbac-domain.model.js +3 -28
  103. package/dist/components/auth/authorize/enforcers/models/rbac-domain.model.js.map +1 -1
  104. package/dist/components/index.js +1 -5
  105. package/dist/components/index.js.map +1 -1
  106. package/dist/components/mail/common/constants.d.ts +2 -3
  107. package/dist/components/mail/common/constants.d.ts.map +1 -1
  108. package/dist/components/mail/common/constants.js +2 -3
  109. package/dist/components/mail/common/constants.js.map +1 -1
  110. package/dist/components/mail/helpers/executors/bull-mq-executor.helper.d.ts +2 -3
  111. package/dist/components/mail/helpers/executors/bull-mq-executor.helper.d.ts.map +1 -1
  112. package/dist/components/mail/helpers/executors/bull-mq-executor.helper.js +2 -3
  113. package/dist/components/mail/helpers/executors/bull-mq-executor.helper.js.map +1 -1
  114. package/dist/components/mail/helpers/transporters/mailgun-transporter.helper.d.ts +2 -3
  115. package/dist/components/mail/helpers/transporters/mailgun-transporter.helper.d.ts.map +1 -1
  116. package/dist/components/mail/helpers/transporters/mailgun-transporter.helper.js +2 -3
  117. package/dist/components/mail/helpers/transporters/mailgun-transporter.helper.js.map +1 -1
  118. package/dist/components/static-asset/controller/base.definition.d.ts +0 -10
  119. package/dist/components/static-asset/controller/base.definition.d.ts.map +1 -1
  120. package/dist/components/static-asset/controller/factory.d.ts +2 -2
  121. package/dist/components/static-asset/controller/factory.d.ts.map +1 -1
  122. package/dist/components/static-asset/controller/factory.js +4 -12
  123. package/dist/components/static-asset/controller/factory.js.map +1 -1
  124. package/dist/connectors/meilisearch/compiler.d.ts +2 -5
  125. package/dist/connectors/meilisearch/compiler.d.ts.map +1 -1
  126. package/dist/connectors/meilisearch/compiler.js.map +1 -1
  127. package/dist/connectors/meilisearch/connector.d.ts +20 -44
  128. package/dist/connectors/meilisearch/connector.d.ts.map +1 -1
  129. package/dist/connectors/meilisearch/connector.js +23 -54
  130. package/dist/connectors/meilisearch/connector.js.map +1 -1
  131. package/dist/connectors/meilisearch/internal/connector-internal.d.ts +4 -12
  132. package/dist/connectors/meilisearch/internal/connector-internal.d.ts.map +1 -1
  133. package/dist/connectors/meilisearch/internal/connector-internal.js +4 -12
  134. package/dist/connectors/meilisearch/internal/connector-internal.js.map +1 -1
  135. package/dist/connectors/meilisearch/repositories/dialect/query-dialect.d.ts +3 -5
  136. package/dist/connectors/meilisearch/repositories/dialect/query-dialect.d.ts.map +1 -1
  137. package/dist/connectors/meilisearch/repositories/dialect/query-dialect.js +7 -16
  138. package/dist/connectors/meilisearch/repositories/dialect/query-dialect.js.map +1 -1
  139. package/dist/connectors/postgres/datasources/abstract.d.ts +9 -24
  140. package/dist/connectors/postgres/datasources/abstract.d.ts.map +1 -1
  141. package/dist/connectors/postgres/datasources/abstract.js +9 -24
  142. package/dist/connectors/postgres/datasources/abstract.js.map +1 -1
  143. package/dist/connectors/postgres/datasources/base.d.ts.map +1 -1
  144. package/dist/connectors/postgres/datasources/base.js +6 -9
  145. package/dist/connectors/postgres/datasources/base.js.map +1 -1
  146. package/dist/connectors/postgres/datasources/common/types.d.ts +8 -16
  147. package/dist/connectors/postgres/datasources/common/types.d.ts.map +1 -1
  148. package/dist/connectors/postgres/datasources/common/types.js.map +1 -1
  149. package/dist/connectors/postgres/drivers/driver.d.ts +9 -21
  150. package/dist/connectors/postgres/drivers/driver.d.ts.map +1 -1
  151. package/dist/connectors/postgres/repositories/dialect/filter.d.ts +8 -26
  152. package/dist/connectors/postgres/repositories/dialect/filter.d.ts.map +1 -1
  153. package/dist/connectors/postgres/repositories/dialect/filter.js +11 -31
  154. package/dist/connectors/postgres/repositories/dialect/filter.js.map +1 -1
  155. package/dist/connectors/postgres/repositories/dialect/query.d.ts.map +1 -1
  156. package/dist/connectors/postgres/repositories/dialect/query.js +3 -12
  157. package/dist/connectors/postgres/repositories/dialect/query.js.map +1 -1
  158. package/dist/connectors/postgres/supabase/pooler.d.ts +3 -8
  159. package/dist/connectors/postgres/supabase/pooler.d.ts.map +1 -1
  160. package/dist/connectors/postgres/supabase/pooler.js +3 -8
  161. package/dist/connectors/postgres/supabase/pooler.js.map +1 -1
  162. package/dist/connectors/postgres/supabase/rls.d.ts +4 -10
  163. package/dist/connectors/postgres/supabase/rls.d.ts.map +1 -1
  164. package/dist/connectors/postgres/supabase/rls.js +4 -10
  165. package/dist/connectors/postgres/supabase/rls.js.map +1 -1
  166. package/dist/connectors/search/connector.d.ts +6 -15
  167. package/dist/connectors/search/connector.d.ts.map +1 -1
  168. package/dist/connectors/search/connector.js.map +1 -1
  169. package/dist/connectors/search/controllers/definition.d.ts +0 -2
  170. package/dist/connectors/search/controllers/definition.d.ts.map +1 -1
  171. package/dist/connectors/search/controllers/factory.d.ts +2 -2
  172. package/dist/connectors/search/datasources/base.d.ts +10 -25
  173. package/dist/connectors/search/datasources/base.d.ts.map +1 -1
  174. package/dist/connectors/search/datasources/base.js +10 -25
  175. package/dist/connectors/search/datasources/base.js.map +1 -1
  176. package/dist/connectors/search/internal/search-connector-internal.d.ts +2 -2
  177. package/dist/connectors/search/internal/search-connector-internal.d.ts.map +1 -1
  178. package/dist/connectors/search/internal/search-connector-internal.js +3 -7
  179. package/dist/connectors/search/internal/search-connector-internal.js.map +1 -1
  180. package/dist/connectors/search/models/base.d.ts +3 -6
  181. package/dist/connectors/search/models/base.d.ts.map +1 -1
  182. package/dist/connectors/search/models/base.js +3 -6
  183. package/dist/connectors/search/models/base.js.map +1 -1
  184. package/dist/connectors/search/models/define-search-collection.d.ts +3 -8
  185. package/dist/connectors/search/models/define-search-collection.d.ts.map +1 -1
  186. package/dist/connectors/search/models/define-search-collection.js +3 -8
  187. package/dist/connectors/search/models/define-search-collection.js.map +1 -1
  188. package/dist/connectors/search/models/types.d.ts +6 -14
  189. package/dist/connectors/search/models/types.d.ts.map +1 -1
  190. package/dist/connectors/search/repositories/common/constants.d.ts +5 -13
  191. package/dist/connectors/search/repositories/common/constants.d.ts.map +1 -1
  192. package/dist/connectors/search/repositories/common/constants.js +11 -31
  193. package/dist/connectors/search/repositories/common/constants.js.map +1 -1
  194. package/dist/connectors/search/repositories/common/dialect-helpers.d.ts +2 -6
  195. package/dist/connectors/search/repositories/common/dialect-helpers.d.ts.map +1 -1
  196. package/dist/connectors/search/repositories/common/dialect-helpers.js +2 -6
  197. package/dist/connectors/search/repositories/common/dialect-helpers.js.map +1 -1
  198. package/dist/connectors/search/repositories/common/types.d.ts +2 -5
  199. package/dist/connectors/search/repositories/common/types.d.ts.map +1 -1
  200. package/dist/connectors/search/repositories/core/base.d.ts +5 -12
  201. package/dist/connectors/search/repositories/core/base.d.ts.map +1 -1
  202. package/dist/connectors/search/repositories/core/base.js +5 -12
  203. package/dist/connectors/search/repositories/core/base.js.map +1 -1
  204. package/dist/connectors/search/repositories/core/persistable.d.ts +8 -17
  205. package/dist/connectors/search/repositories/core/persistable.d.ts.map +1 -1
  206. package/dist/connectors/search/repositories/core/persistable.js +10 -21
  207. package/dist/connectors/search/repositories/core/persistable.js.map +1 -1
  208. package/dist/connectors/search/repositories/core/readable.d.ts +3 -7
  209. package/dist/connectors/search/repositories/core/readable.d.ts.map +1 -1
  210. package/dist/connectors/search/repositories/core/readable.js +3 -7
  211. package/dist/connectors/search/repositories/core/readable.js.map +1 -1
  212. package/dist/connectors/typesense/connector.d.ts.map +1 -1
  213. package/dist/connectors/typesense/connector.js.map +1 -1
  214. package/dist/connectors/typesense/repositories/common/types.d.ts +3 -6
  215. package/dist/connectors/typesense/repositories/common/types.d.ts.map +1 -1
  216. package/dist/helpers/inversion/common/keys.d.ts +0 -1
  217. package/dist/helpers/inversion/common/keys.d.ts.map +1 -1
  218. package/dist/helpers/inversion/common/types.d.ts +1 -5
  219. package/dist/helpers/inversion/common/types.d.ts.map +1 -1
  220. package/dist/helpers/inversion/container.d.ts +2 -2
  221. package/dist/helpers/inversion/container.d.ts.map +1 -1
  222. package/dist/helpers/inversion/mixins/controllers/controller.d.ts +0 -7
  223. package/dist/helpers/inversion/mixins/controllers/controller.d.ts.map +1 -1
  224. package/dist/helpers/inversion/mixins/controllers/grpc.d.ts +0 -7
  225. package/dist/helpers/inversion/mixins/controllers/grpc.d.ts.map +1 -1
  226. package/dist/helpers/inversion/mixins/controllers/rest.d.ts +0 -7
  227. package/dist/helpers/inversion/mixins/controllers/rest.d.ts.map +1 -1
  228. package/dist/helpers/inversion/mixins/datasource.mixin.d.ts +0 -7
  229. package/dist/helpers/inversion/mixins/datasource.mixin.d.ts.map +1 -1
  230. package/dist/helpers/inversion/mixins/model.mixin.d.ts +0 -7
  231. package/dist/helpers/inversion/mixins/model.mixin.d.ts.map +1 -1
  232. package/dist/helpers/inversion/mixins/repository.mixin.d.ts +4 -18
  233. package/dist/helpers/inversion/mixins/repository.mixin.d.ts.map +1 -1
  234. package/dist/helpers/inversion/mixins/repository.mixin.js +5 -9
  235. package/dist/helpers/inversion/mixins/repository.mixin.js.map +1 -1
  236. package/dist/helpers/inversion/registry.d.ts +0 -42
  237. package/dist/helpers/inversion/registry.d.ts.map +1 -1
  238. package/dist/utilities/error.utility.d.ts +2 -2
  239. package/dist/utilities/error.utility.d.ts.map +1 -1
  240. package/dist/utilities/jsx.utility.d.ts +0 -1
  241. package/dist/utilities/jsx.utility.d.ts.map +1 -1
  242. package/package.json +17 -17
package/README.md CHANGED
@@ -1,235 +1,64 @@
1
1
  <div align="center">
2
2
 
3
- # :fire: IGNIS - @venizia/ignis
3
+ # :fire: @venizia/ignis
4
4
 
5
- **High-performance TypeScript server infrastructure combining enterprise-grade architecture with Hono speed.**
5
+ **The IGNIS framework core - LoopBack 4's architecture on Hono's speed.**
6
6
 
7
7
  [![npm](https://img.shields.io/npm/v/@venizia/ignis.svg?style=flat-square&color=cb3837)](https://www.npmjs.com/package/@venizia/ignis)
8
- [![License](https://img.shields.io/badge/License-MIT-3DA639.svg?style=flat-square)](https://opensource.org/licenses/MIT)
9
- [![TypeScript](https://img.shields.io/badge/TypeScript-5.x-3178C6.svg?style=flat-square&logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
10
- [![Hono](https://img.shields.io/badge/Hono-4.x-E36002.svg?style=flat-square&logo=hono&logoColor=white)](https://hono.dev/)
11
- [![Drizzle ORM](https://img.shields.io/badge/Drizzle_ORM-0.45-C5F74F.svg?style=flat-square)](https://orm.drizzle.team/)
8
+ [![Docs](https://img.shields.io/badge/Docs-ignis.venizia.ai-2563EB.svg?style=flat-square)](https://ignis.venizia.ai)
9
+ [![License: MIT](https://img.shields.io/badge/License-MIT-3DA639.svg?style=flat-square)](LICENSE.md)
10
+ [![TypeScript](https://img.shields.io/badge/TypeScript-6.x-3178C6.svg?style=flat-square&logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
12
11
 
13
- 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: decorator-based DI, repository pattern, DataSource abstraction, component system, boot conventions - running on Hono's ~140k req/s engine with Drizzle ORM's type-safe SQL.
14
-
15
- [Installation](#installation) &#8226; [Quick Start](#quick-start) &#8226; [API Reference](#controllers) &#8226; [Documentation](https://venizia-ai.github.io/ignis)
12
+ [Documentation](https://ignis.venizia.ai) &#8226;
13
+ [Quickstart](https://ignis.venizia.ai/guides/get-started/5-minute-quickstart) &#8226;
14
+ [API Reference](https://ignis.venizia.ai/references/)
16
15
 
17
16
  </div>
18
17
 
19
- ## Highlights
20
-
21
- | | Feature | |
22
- | :---: | :--- | :--- |
23
- | **1** | **Zero-Config CRUD** | 2-line repository gives you full create/read/update/delete |
24
- | **2** | **Type-Safe SQL** | End-to-end TypeScript inference with Drizzle ORM |
25
- | **3** | **Auto OpenAPI** | Every route produces Swagger documentation automatically |
26
- | **4** | **~140k req/s** | Hono-powered HTTP with zero wrapper overhead |
27
- | **5** | **9 Built-in Components** | Auth, Health, Swagger, Mail, Socket.IO, Static Assets, and more |
28
- | **6** | **3 Route Patterns** | Decorator, imperative, or fluent -- your choice |
29
-
30
- ---
31
-
32
- ## At a Glance
33
-
34
- ```typescript
35
- import {
36
- BaseApplication, // Your app extends this
37
- BaseRestController, // Controllers extend this
38
- } from '@venizia/ignis';
39
- import {
40
- DefaultCRUDRepository, // Repositories extend this
41
- BasePostgresEntity, // Models extend this
42
- BasePostgresDataSource, // DataSources extend this
43
- } from '@venizia/ignis/postgres';
44
- ```
45
-
46
- ---
47
-
48
- ## Table of Contents
49
-
50
- - [Installation](#installation)
51
- - [Quick Start](#quick-start)
52
- - [Application Lifecycle](#application-lifecycle)
53
- - [Application Configuration](#application-configuration)
54
- - [Controllers](#controllers)
55
- - [Repositories](#repositories)
56
- - [Models](#models)
57
- - [DataSources](#datasources)
58
- - [Search](#search)
59
- - [Memory Connector](#memory-connector)
60
- - [Services](#services)
61
- - [Components](#components)
62
- - [Request Context](#request-context)
63
- - [Middleware System](#middleware-system)
64
- - [Error Handling](#error-handling)
65
- - [Decorators Reference](#decorators-reference)
66
- - [Response Helpers](#response-helpers)
67
- - [Real-World Patterns](#real-world-patterns)
68
- - [Testing](#testing)
69
- - [Performance Tips](#performance-tips)
70
- - [Documentation](#documentation)
71
- - [License](#license)
72
-
73
18
  ---
74
19
 
75
- ## Installation
20
+ This is the framework itself: the application class, controllers, repositories, models, datasources,
21
+ the decorator set, and the component system. You extend the base classes; IGNIS wires them through
22
+ its IoC container, validates every request against Zod, and generates the OpenAPI spec from the same
23
+ schemas.
76
24
 
77
- ```bash
78
- bun add @venizia/ignis
79
- ```
80
-
81
- ### Required Peer Dependencies
82
-
83
- ```bash
84
- bun add hono @hono/zod-openapi drizzle-orm drizzle-zod pg jose @asteasolutions/zod-to-openapi
85
- ```
25
+ Reach for it when you are building a structured API - real auth, several models, more than a handful
26
+ of endpoints - and you want that structure to survive team growth. For a 3-endpoint service, plain
27
+ Hono is lighter.
86
28
 
87
- ### Optional Peer Dependencies
88
-
89
- Install only what you use:
29
+ ## Install
90
30
 
91
31
  ```bash
92
- # Swagger / API Reference UI
93
- bun add @hono/swagger-ui @scalar/hono-api-reference
94
-
95
- # Node.js runtime (if not using Bun)
96
- bun add @hono/node-server
97
-
98
- # Socket.IO real-time
99
- bun add socket.io socket.io-client @socket.io/bun-engine
100
-
101
- # Redis adapter for Socket.IO horizontal scaling
102
- bun add @socket.io/redis-adapter @socket.io/redis-emitter
103
-
104
- # Background job queues
105
- bun add bullmq
106
-
107
- # Authorization (Casbin RBAC)
108
- bun add casbin
109
-
110
- # Email
111
- bun add nodemailer mailgun.js
112
-
113
- # Search (Typesense)
114
- bun add typesense
115
-
116
- # gRPC controller transport
117
- bun add @connectrpc/connect
118
- ```
119
-
120
- ---
121
-
122
- ## Quick Start
123
-
124
- ### 1. Define a Model
125
-
126
- ```typescript
127
- // models/user.model.ts
128
- import { model } from '@venizia/ignis';
129
- import { BasePostgresEntity, generateIdColumnDefs, generateTzColumnDefs } from '@venizia/ignis/postgres';
130
- import { pgTable, text } from 'drizzle-orm/pg-core';
131
-
132
- @model({
133
- type: 'entity',
134
- settings: {
135
- hiddenProperties: ['password'],
136
- },
137
- })
138
- export class User extends BasePostgresEntity<typeof User.schema> {
139
- static override schema = pgTable('User', {
140
- ...generateIdColumnDefs({ id: { dataType: 'string' } }),
141
- ...generateTzColumnDefs(),
142
- username: text('username').notNull().unique(),
143
- email: text('email').notNull().unique(),
144
- password: text('password'),
145
- });
146
-
147
- static override relations = () => [];
148
- }
149
- ```
150
-
151
- ### 2. Define a DataSource
152
-
153
- ```typescript
154
- // datasources/postgres.datasource.ts
155
- import { datasource, ValueOrPromise } from '@venizia/ignis';
156
- import { BasePostgresDataSource } from '@venizia/ignis/postgres';
157
- import { drizzle } from 'drizzle-orm/node-postgres';
158
- import { Pool } from 'pg';
159
-
160
- interface IDSConfigs {
161
- host: string;
162
- port: number;
163
- database: string;
164
- user: string;
165
- password: string;
166
- // Optional pg Pool tuning (see "Complete DataSource Configuration" below)
167
- max?: number;
168
- idleTimeoutMillis?: number;
169
- connectionTimeoutMillis?: number;
170
- }
171
-
172
- @datasource({ driver: 'node-postgres' })
173
- export class PostgresDataSource extends BasePostgresDataSource<IDSConfigs> {
174
- constructor() {
175
- super({
176
- name: PostgresDataSource.name,
177
- config: {
178
- host: process.env.DB_HOST!,
179
- port: +(process.env.DB_PORT ?? 5432),
180
- database: process.env.DB_NAME!,
181
- user: process.env.DB_USER!,
182
- password: process.env.DB_PASSWORD!,
183
- },
184
- // Schema is auto-discovered from @repository bindings
185
- });
186
- }
187
-
188
- override configure(): ValueOrPromise<void> {
189
- const schema = this.getSchema();
190
- this.pool = new Pool(this.settings);
191
- this.connector = drizzle({ client: this.pool, schema });
192
- }
193
-
194
- override getConnectionString() {
195
- const { host, port, user, password, database } = this.settings;
196
- return `postgresql://${user}:${password}@${host}:${port}/${database}`;
197
- }
198
- }
32
+ bun add @venizia/ignis @venizia/ignis-helpers hono @hono/zod-openapi drizzle-orm pg
33
+ bun add -d typescript tsc-alias @venizia/dev-configs @types/bun
199
34
  ```
200
35
 
201
- ### 3. Define a Repository
202
-
203
- ```typescript
204
- // repositories/user.repository.ts
205
- import { repository } from '@venizia/ignis';
206
- import { PersistableRepository } from '@venizia/ignis/postgres';
207
- import { User } from '../models/user.model';
208
- import { PostgresDataSource } from '../datasources/postgres.datasource';
36
+ `@venizia/ignis-boot`, `@venizia/ignis-helpers` and `@venizia/ignis-inversion` come along as
37
+ dependencies. `hono`, `@hono/zod-openapi`, `@asteasolutions/zod-to-openapi`, `drizzle-orm` and
38
+ `drizzle-zod` are required peers. Everything else in `peerDependencies` is optional - install only
39
+ what the components and connectors you actually use need.
209
40
 
210
- @repository({ model: User, dataSource: PostgresDataSource })
211
- export class UserRepository extends PersistableRepository<typeof User.schema> {
212
- // No constructor needed -- DataSource is auto-injected at param[0]
213
- }
214
- ```
41
+ > [!IMPORTANT]
42
+ > `experimentalDecorators` and `emitDecoratorMetadata` must be `true` in your `tsconfig.json`,
43
+ > declared **inline**. Bun does not resolve them through `extends`, and `@inject` metadata is
44
+ > silently dropped without them - bindings then fail at boot with no obvious cause. Copy them from
45
+ > `@venizia/dev-configs/tsconfig.common.json`.
215
46
 
216
- ### 4. Define a Controller
47
+ ## Minimal application
217
48
 
218
49
  ```typescript
219
- // controllers/user.controller.ts
50
+ import { z } from '@hono/zod-openapi';
220
51
  import {
221
- BaseRestController, controller, get, post,
222
- inject, jsonContent, jsonResponse, TRouteContext,
52
+ ApiReferenceComponent, BaseApplication, BaseRestController,
53
+ controller, get, IApplicationInfo, jsonContent,
223
54
  } from '@venizia/ignis';
224
- import { z } from '@hono/zod-openapi';
225
- import { UserRepository } from '../repositories/user.repository';
55
+ import { HTTP } from '@venizia/ignis-helpers';
56
+ import { Context } from 'hono';
226
57
 
227
- @controller({ path: '/users' })
228
- export class UserController extends BaseRestController {
229
- constructor(
230
- @inject({ key: 'repositories.UserRepository' }) private userRepository: UserRepository,
231
- ) {
232
- super({ scope: UserController.name });
58
+ @controller({ path: '/hello' })
59
+ class HelloController extends BaseRestController {
60
+ constructor() {
61
+ super({ scope: 'HelloController', path: '/hello' });
233
62
  }
234
63
 
235
64
  override binding() {}
@@ -237,2244 +66,151 @@ export class UserController extends BaseRestController {
237
66
  @get({
238
67
  configs: {
239
68
  path: '/',
240
- responses: jsonResponse({
241
- schema: z.array(z.object({ id: z.string(), username: z.string(), email: z.string() })),
242
- }),
243
- },
244
- })
245
- async listUsers(context: TRouteContext) {
246
- const users = await this.userRepository.find({ filter: {} });
247
- return context.json(users, 200);
248
- }
249
-
250
- @post({
251
- configs: {
252
- path: '/',
253
- request: {
254
- body: jsonContent({
255
- description: 'New user data',
256
- schema: z.object({ username: z.string(), email: z.string(), password: z.string() }),
69
+ responses: {
70
+ [HTTP.ResultCodes.RS_2.Ok]: jsonContent({
71
+ description: 'Says hello',
72
+ schema: z.object({ message: z.string() }),
257
73
  }),
258
74
  },
259
- responses: jsonResponse({
260
- schema: z.object({ count: z.number(), data: z.any() }),
261
- }),
262
75
  },
263
76
  })
264
- async createUser(context: TRouteContext) {
265
- const body = context.req.valid<{ username: string; email: string; password: string }>('json');
266
- const result = await this.userRepository.create({ data: body });
267
- return context.json(result, 200);
77
+ sayHello(c: Context) {
78
+ return c.json({ message: 'Hello from IGNIS!' }, HTTP.ResultCodes.RS_2.Ok);
268
79
  }
269
80
  }
270
- ```
271
-
272
- ### 5. Define the Application
273
-
274
- ```typescript
275
- // application.ts
276
- import {
277
- BaseApplication, IApplicationConfigs, IApplicationInfo,
278
- HealthCheckComponent, SwaggerComponent, ValueOrPromise,
279
- } from '@venizia/ignis';
280
- import { PostgresDataSource } from './datasources/postgres.datasource';
281
- import { UserRepository } from './repositories/user.repository';
282
- import { UserController } from './controllers/user.controller';
283
-
284
- const configs: IApplicationConfigs = {
285
- host: 'localhost',
286
- port: 3000,
287
- path: { base: '/api', isStrict: true },
288
- };
289
-
290
- export class Application extends BaseApplication {
291
- constructor() {
292
- super({ scope: Application.name, config: configs });
293
- this.init();
294
- }
295
81
 
82
+ class App extends BaseApplication {
296
83
  getAppInfo(): IApplicationInfo {
297
- return { name: 'My App', version: '1.0.0', description: 'My IGNIS application' };
84
+ return { name: 'my-app', version: '1.0.0', description: 'My first IGNIS app' };
298
85
  }
299
86
 
300
87
  staticConfigure() {}
88
+ postConfigure() {}
89
+ setupMiddlewares() {}
301
90
 
302
- preConfigure(): ValueOrPromise<void> {
303
- // Register components
304
- this.component(HealthCheckComponent);
305
- this.component(SwaggerComponent);
306
-
307
- // Register datasources, repositories, and controllers
308
- this.dataSource(PostgresDataSource);
309
- this.repository(UserRepository);
310
- this.controller(UserController);
311
- }
312
-
313
- postConfigure(): ValueOrPromise<void> {}
314
-
315
- setupMiddlewares(): ValueOrPromise<void> {
316
- // Add CORS, body limit, etc.
317
- }
318
- }
319
- ```
320
-
321
- ### 6. Start the Server
322
-
323
- ```typescript
324
- // index.ts
325
- import { Application } from './application';
326
-
327
- const app = new Application();
328
- app.start();
329
- ```
330
-
331
- ---
332
-
333
- ## Application Lifecycle
334
-
335
- `BaseApplication` extends the IoC `Container` and orchestrates a well-defined startup sequence:
336
-
337
- ```
338
- 1. init() Register core bindings (app instance, server, root router)
339
- 2. start() Entry point -- calls initialize() then starts the server
340
- |
341
- +-- initialize()
342
- | |
343
- | +-- printStartUpInfo() Log environment, runtime, timezone, datasource info
344
- | +-- validateEnvs() Validate required environment variables
345
- | +-- registerDefaultMiddlewares() Error handler, async context, request tracker, favicon
346
- | +-- staticConfigure() Pre-DI static setup (e.g., serve static files)
347
- | +-- preConfigure() Register controllers, services, components, datasources
348
- | +-- registerDataSources() Configure all datasources (auto-discover schemas)
349
- | +-- registerComponents() Configure all components (can register more datasources)
350
- | +-- registerControllers() Configure controllers, mount routes on root router
351
- | +-- postConfigure() Post-registration hooks
352
- |
353
- +-- setupMiddlewares() Register Hono middlewares (CORS, body limit, etc.)
354
- +-- mount root router Mount to base path
355
- +-- startBunModule / startNodeModule Start HTTP server
356
- +-- executePostStartHooks() Run any registered post-start hooks
357
- ```
358
-
359
- ### What Happens Inside Each Phase
360
-
361
- **`registerDefaultMiddlewares()`** -- Automatically sets up:
362
-
363
- - `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.
364
- - `contextStorage()` -- Hono async context storage for accessing request context anywhere (enabled by default, controlled via `asyncContext.enable` config).
365
- - `RequestTrackerComponent` -- Injects `x-request-id` header on every request and parses request body.
366
- - `emojiFavicon` -- Returns a favicon emoji response (configurable via `favicon` config).
367
- - `notFoundHandler` -- Returns a structured 404 response for unmatched routes.
368
-
369
- **`registerDataSources()`** -- Iterates all bindings tagged `datasources`, calls `configure()` on each. Schema auto-discovery happens here.
370
-
371
- **`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).
372
-
373
- **`registerControllers()`** -- Dispatches per configured transport (`configs.transports`, default `['rest']`): a `RestComponent` handles REST controllers (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), and, when `'grpc'` is included, a `GrpcComponent` handles gRPC controllers. A gRPC controller discovered without `'grpc'` in `transports` is logged as an error and skipped.
374
-
375
- ### Key Application Methods
376
-
377
- | Method | Description |
378
- | --- | --- |
379
- | `controller(ctor)` | Register a controller class -- bound to `controllers.{Name}` |
380
- | `service(ctor)` | Register a service class -- bound to `services.{Name}` |
381
- | `repository(ctor)` | Register a repository class -- bound to `repositories.{Name}` |
382
- | `dataSource(ctor)` | Register a datasource class (singleton) -- bound to `datasources.{Name}` |
383
- | `component(ctor)` | Register a component class (singleton) -- bound to `components.{Name}` |
384
- | `static({ folderPath })` | Serve static files (auto-detects Bun/Node runtime) |
385
- | `getServer()` | Get the main `OpenAPIHono` instance |
386
- | `getServerPort()` | Get the configured server port |
387
- | `getServerHost()` | Get the configured server host |
388
- | `getServerAddress()` | Get `host:port` string |
389
- | `getRootRouter()` | Get the root router for direct route registration |
390
- | `getProjectRoot()` | Get the project working directory |
391
- | `getProjectConfigs()` | Get the full application configuration object |
392
- | `getServerInstance()` | Get the underlying Bun.Server or Node HTTP server instance |
393
- | `registerPostStartHook({ identifier, hook })` | Register a callback to run after server starts |
394
- | `boot()` | Convention-based auto-discovery (controllers, services, repositories, datasources) |
395
- | `stop()` | Gracefully stop the server |
396
-
397
- ### `registerDynamicBindings()` -- Handling Late/Circular Registrations
398
-
399
- 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:
400
-
401
- ```typescript
402
- protected async registerDynamicBindings<T extends IConfigurable>(opts: {
403
- namespace: TBindingNamespace;
404
- onBeforeConfigure?: (opts: { binding: Binding<T> }) => Promise<void>;
405
- onAfterConfigure?: (opts: { binding: Binding<T>; instance: T }) => Promise<void>;
406
- }): Promise<void>;
407
- ```
408
-
409
- It works by:
410
-
411
- 1. Fetching all bindings for the given namespace, excluding already-configured ones.
412
- 2. Configuring each binding in sequence.
413
- 3. After each configuration, re-fetching bindings to pick up any newly added ones.
414
- 4. Repeating until no new bindings remain.
415
-
416
- This is critical for components that register datasources during their own configuration.
417
-
418
- ### `registerPostStartHook()` -- Running Code After Server Start
419
-
420
- ```typescript
421
- // In preConfigure() or postConfigure():
422
- this.registerPostStartHook({
423
- identifier: 'warmup-cache',
424
- hook: async () => {
425
- const cacheService = this.get<CacheService>({ key: 'services.CacheService' });
426
- await cacheService.warmup();
427
- console.log('Cache warmed up');
428
- },
429
- });
430
-
431
- this.registerPostStartHook({
432
- identifier: 'register-cron-jobs',
433
- hook: async () => {
434
- const cronService = this.get<CronService>({ key: 'services.CronService' });
435
- cronService.startAll();
436
- },
437
- });
438
- ```
439
-
440
- Post-start hooks execute sequentially after the HTTP server is listening. Each hook is logged with its execution time.
441
-
442
- ### Static File Serving
443
-
444
- ```typescript
445
- staticConfigure() {
446
- // Serve files from ./public directory at all unmatched routes
447
- this.static({ folderPath: './public' });
448
-
449
- // Or serve at a specific path
450
- this.static({ restPath: '/assets/*', folderPath: './static-assets' });
451
- }
452
- ```
453
-
454
- Runtime-aware: uses `hono/bun` `serveStatic` on Bun, `@hono/node-server/serve-static` on Node.js.
455
-
456
- ### Runtime Detection
457
-
458
- IGNIS auto-detects the runtime and starts the server accordingly:
459
-
460
- ```typescript
461
- // Bun (default)
462
- Bun.serve({ port, hostname, fetch: server.fetch });
463
-
464
- // Node.js (requires @hono/node-server)
465
- import { serve } from '@hono/node-server';
466
- serve({ fetch: server.fetch, port, hostname });
467
- ```
468
-
469
- The runtime is detected via `RuntimeModules.detect()` which checks for the presence of the global `Bun` object.
470
-
471
- ---
472
-
473
- ## Application Configuration
474
-
475
- ```typescript
476
- interface IApplicationConfigs {
477
- host?: string; // Server host (default: 'localhost' or APP_ENV_SERVER_HOST/HOST env)
478
- port?: number; // Server port (default: 3000 or PORT/APP_ENV_SERVER_PORT env)
479
-
480
- path: {
481
- base: string; // Base path for all routes (e.g., '/api')
482
- isStrict: boolean; // Currently unused -- see strictPath below
483
- };
484
-
485
- strictPath?: boolean; // When true (default), '/users' and '/users/' are different routes
486
-
487
- requestId?: {
488
- isStrict: boolean; // Enforce request ID on all requests
489
- };
490
-
491
- favicon?: string; // Emoji favicon (default: fire emoji)
492
-
493
- error?: {
494
- rootKey: string; // Wrap error responses in this key (e.g., 'error')
495
- };
496
-
497
- asyncContext?: {
498
- enable: boolean; // Enable Hono async context storage (default: true)
499
- };
500
-
501
- bootOptions?: IBootOptions; // Convention-based auto-discovery options
502
-
503
- transports?: ('rest' | 'grpc')[]; // Controller transports to enable (default: ['rest'])
504
-
505
- debug?: {
506
- shouldShowRoutes?: boolean; // Print all registered routes on startup
507
- };
508
- }
509
- ```
510
-
511
- **Note:** the actual Hono strict-routing flag is read from the top-level `strictPath` (defaulting to `true`), not from `path.isStrict` -- `path.isStrict` is declared on the type but not currently read anywhere.
512
-
513
- ```typescript
514
- interface IApplicationInfo {
515
- name: string;
516
- version: string;
517
- description: string;
518
- author?: { name: string; email: string; url?: string };
519
- }
520
- ```
521
-
522
- ---
523
-
524
- ## Controllers
525
-
526
- ### BaseRestController
527
-
528
- All controllers extend `BaseRestController`, which provides:
529
-
530
- - An `OpenAPIHono` router instance
531
- - Route registration methods (`defineRoute`, `bindRoute`, `defineJSXRoute`)
532
- - Automatic authentication and authorization middleware injection
533
- - OpenAPI schema generation and route tagging
534
- - Zod-based request validation with automatic 422 error responses
535
-
536
- ```typescript
537
- abstract class BaseRestController extends AbstractRestController {
538
- // Register routes -- override this method
539
- abstract binding(): ValueOrPromise<void>;
540
-
541
- // Imperative route definition
542
- defineRoute({ configs, handler, hook? });
543
-
544
- // Fluent two-step route definition
545
- bindRoute({ configs }).to({ handler });
546
-
547
- // JSX/HTML route definition (server-side rendering)
548
- defineJSXRoute({ configs, handler });
549
-
550
- // Get the router for this controller
551
- getRouter(): OpenAPIHono;
552
- }
553
- ```
554
-
555
- ### Three Route Definition Patterns
556
-
557
- #### 1. Decorator Pattern
558
-
559
- Use `@get`, `@post`, `@put`, `@patch`, `@del`, or the generic `@api` decorators. Decorator-based routes are automatically registered during `configure()` via `registerRoutesFromRegistry()`:
560
-
561
- ```typescript
562
- @controller({ path: '/products' })
563
- class ProductController extends BaseRestController {
564
- constructor(
565
- @inject({ key: 'repositories.ProductRepository' }) private productRepository: ProductRepository,
566
- @inject({ key: 'services.InventoryService' }) private inventoryService: InventoryService,
567
- ) {
568
- super({ scope: ProductController.name });
569
- }
570
-
571
- override binding() {} // decorator routes are auto-registered
572
-
573
- @get({
574
- configs: {
575
- path: '/',
576
- description: 'List all products with pagination',
577
- responses: jsonResponse({
578
- schema: z.array(z.object({
579
- id: z.number(),
580
- name: z.string(),
581
- price: z.number(),
582
- category: z.string(),
583
- })),
584
- description: 'Array of products',
585
- }),
586
- },
587
- })
588
- async list(context: TRouteContext) {
589
- const products = await this.productRepository.find({
590
- filter: { order: ['createdAt DESC'], limit: 20 },
591
- });
592
- return context.json(products, 200);
593
- }
594
-
595
- @get({
596
- configs: {
597
- path: '/{id}',
598
- request: {
599
- params: z.object({ id: z.string().pipe(z.coerce.number()) }),
600
- },
601
- responses: jsonResponse({
602
- schema: z.object({
603
- id: z.number(),
604
- name: z.string(),
605
- price: z.number(),
606
- stock: z.number(),
607
- }),
608
- }),
609
- },
610
- })
611
- async getById(context: TRouteContext) {
612
- const { id } = context.req.valid<{ id: number }>('param');
613
- const product = await this.productRepository.findById({ id });
614
- if (!product) {
615
- return context.json({ message: 'Product not found' }, 404);
616
- }
617
- return context.json(product, 200);
618
- }
619
-
620
- @post({
621
- configs: {
622
- path: '/',
623
- authenticate: { strategies: ['jwt'] },
624
- request: {
625
- body: jsonContent({
626
- schema: z.object({
627
- name: z.string().min(1).max(255),
628
- price: z.number().positive(),
629
- category: z.string(),
630
- description: z.string().optional(),
631
- }),
632
- description: 'New product data',
633
- }),
634
- },
635
- responses: jsonResponse({
636
- schema: z.object({ count: z.number(), data: z.any() }),
637
- }),
638
- },
639
- })
640
- async create(context: TRouteContext) {
641
- const data = context.req.valid<{
642
- name: string;
643
- price: number;
644
- category: string;
645
- description?: string;
646
- }>('json');
647
- const result = await this.productRepository.create({ data });
648
- return context.json(result, 200);
91
+ preConfigure() {
92
+ this.component(ApiReferenceComponent); // interactive docs at /doc/explorer
93
+ this.controller(HelloController);
649
94
  }
650
95
  }
651
- ```
652
-
653
- #### 2. Imperative Pattern
654
-
655
- Define routes directly inside `binding()`:
656
-
657
- ```typescript
658
- override binding() {
659
- this.defineRoute({
660
- configs: {
661
- path: '/',
662
- method: 'get',
663
- description: 'List products',
664
- responses: jsonResponse({ schema: z.array(ProductSchema) }),
665
- },
666
- handler: async (context) => {
667
- const products = await this.productRepository.find({ filter: {} });
668
- return context.json(products, 200);
669
- },
670
- });
671
-
672
- this.defineRoute({
673
- configs: {
674
- path: '/{id}',
675
- method: 'delete',
676
- authenticate: { strategies: ['jwt'] },
677
- authorize: { action: 'delete', resource: 'Product' },
678
- request: { params: idParamsSchema({ idType: 'number' }) },
679
- responses: jsonResponse({ schema: z.object({ count: z.number() }) }),
680
- },
681
- handler: async (context) => {
682
- const { id } = context.req.valid<{ id: number }>('param');
683
- const result = await this.productRepository.deleteById({ id });
684
- return context.json(result, 200);
685
- },
686
- });
687
- }
688
- ```
689
-
690
- #### 3. Fluent Pattern
691
-
692
- Two-step binding with `bindRoute().to()`:
693
-
694
- ```typescript
695
- override binding() {
696
- this.bindRoute({
697
- configs: {
698
- path: '/{id}',
699
- method: 'get',
700
- request: { params: idParamsSchema({ idType: 'number' }) },
701
- responses: jsonResponse({ schema: ProductSchema }),
702
- },
703
- }).to({
704
- handler: async (context) => {
705
- const { id } = context.req.valid<{ id: number }>('param');
706
- const product = await this.productRepository.findById({ id });
707
- return context.json(product, 200);
708
- },
709
- });
710
- }
711
- ```
712
-
713
- ### `getRouteConfigs()` -- How Auth Middleware is Injected
714
-
715
- When you specify `authenticate` or `authorize` on a route config, `getRouteConfigs()` automatically:
716
-
717
- 1. Converts `authenticate.strategies` into OpenAPI security specs for documentation.
718
- 2. Creates an `authenticate` middleware based on strategies and mode, and prepends it to the middleware chain.
719
- 3. Creates an `authorize` middleware (if configured) and appends it after authenticate.
720
- 4. Merges any custom `middleware` array from the config.
721
- 5. Adds the controller's scope name as an OpenAPI tag.
722
-
723
- This means you never manually wire auth middleware -- it is all declarative.
724
-
725
- ### Middleware Chaining on Routes
726
-
727
- You can pass additional Hono middleware to any route:
728
-
729
- ```typescript
730
- import { rateLimiter } from 'hono-rate-limiter'; // separate package -- Hono ships no built-in rate limiter
731
- import { cors } from 'hono/cors';
732
-
733
- @post({
734
- configs: {
735
- path: '/upload',
736
- middleware: [
737
- rateLimiter({ windowMs: 60_000, limit: 10 }),
738
- cors({ origin: 'https://myapp.com' }),
739
- ],
740
- authenticate: { strategies: ['jwt'] },
741
- // ...
742
- },
743
- })
744
- async uploadFile(context: TRouteContext) { /* ... */ }
745
- ```
746
-
747
- Middleware execution order: `authenticate` -> `authorize` -> custom middleware -> handler.
748
-
749
- ### Request Validation with Zod
750
96
 
751
- Routes automatically validate request parameters, query strings, headers, and body against Zod schemas. Invalid requests return a `422 Unprocessable Entity` with structured error details:
752
-
753
- ```typescript
754
- @post({
755
- configs: {
756
- path: '/',
757
- request: {
758
- body: jsonContent({
759
- schema: z.object({
760
- email: z.string().email('Invalid email format'),
761
- age: z.number().int().min(18, 'Must be at least 18'),
762
- role: z.enum(['admin', 'user', 'moderator']),
763
- }),
764
- description: 'New user data',
765
- }),
766
- query: z.object({
767
- dryRun: z.string().optional().transform(v => v === 'true'),
768
- }),
769
- headers: z.object({
770
- 'x-api-key': z.string().min(1),
771
- }),
772
- },
773
- responses: jsonResponse({ schema: UserSchema }),
774
- },
97
+ new App({
98
+ scope: 'App',
99
+ config: { host: '0.0.0.0', port: 3000, path: { base: '/api', isStrict: false } },
775
100
  })
776
- async createUser(context: TRouteContext) {
777
- const body = context.req.valid<{ email: string; age: number; role: string }>('json');
778
- const { dryRun } = context.req.valid<{ dryRun?: boolean }>('query');
779
- const apiKey = context.req.valid<{ 'x-api-key': string }>('header');
780
- // All validated -- proceed safely
781
- }
782
- ```
783
-
784
- On validation failure, the error handler returns:
785
-
786
- ```json
787
- {
788
- "message": "Invalid email format",
789
- "messageCode": "invalid_format",
790
- "statusCode": 422,
791
- "requestId": "abc-123",
792
- "details": {
793
- "url": "http://localhost:3000/api/users",
794
- "path": "/api/users",
795
- "cause": [
796
- { "path": "email", "message": "Invalid email format", "code": "invalid_format" },
797
- { "path": "age", "message": "Must be at least 18", "code": "too_small" }
798
- ]
799
- }
800
- }
101
+ .start()
102
+ .catch((error: unknown) => {
103
+ console.error('[main] Application start failed | Error:', error);
104
+ process.exit(1);
105
+ });
801
106
  ```
802
107
 
803
- Top-level `message`/`messageCode` come from the first Zod issue (or a schema-supplied `params.code`, if present); `"ValidationError"` is only used as a fallback when no issue list can be parsed at all. `details.stack` is included alongside `cause` outside production.
804
-
805
- ### Accessing Hono Context
806
-
807
- The `context` parameter (`TRouteContext`) provides full access to the Hono request/response:
808
-
809
- ```typescript
810
- async myHandler(context: TRouteContext) {
811
- // Request data
812
- const body = context.req.valid<MyType>('json');
813
- const params = context.req.valid<{ id: number }>('param');
814
- const query = context.req.valid<{ page: number }>('query');
815
-
816
- // Raw request access
817
- const url = context.req.url;
818
- const method = context.req.method;
819
- const path = context.req.path;
820
- const userAgent = context.req.header('user-agent');
821
- const allHeaders = context.req.raw.headers;
822
-
823
- // Authenticated user (set by auth middleware)
824
- const currentUser = context.get('auth.current.user');
825
- const auditUserId = context.get('audit.user.id');
826
-
827
- // Set response headers
828
- context.header('X-Custom-Header', 'value');
829
- context.header('Cache-Control', 'no-store');
830
-
831
- // Response types
832
- return context.json({ data: 'value' }, 200);
833
- return context.text('plain text', 200);
834
- return context.html('<h1>Hello</h1>');
835
- return context.redirect('/other-page');
836
- return context.body(null, 204); // No content
837
- }
838
- ```
839
-
840
- ### File Upload Handling
841
-
842
- ```typescript
843
- @post({
844
- configs: {
845
- path: '/upload',
846
- authenticate: { strategies: ['jwt'] },
847
- responses: jsonResponse({ schema: z.object({ filename: z.string(), size: z.number() }) }),
848
- },
849
- })
850
- async upload(context: TRouteContext) {
851
- const body = await context.req.parseBody();
852
- const file = body['file'];
853
-
854
- if (file instanceof File) {
855
- const buffer = await file.arrayBuffer();
856
- // Process file...
857
- return context.json({ filename: file.name, size: file.size }, 200);
858
- }
859
-
860
- return context.json({ message: 'No file provided' }, 400);
861
- }
862
- ```
863
-
864
- ### Streaming Responses
865
-
866
- ```typescript
867
- @get({
868
- configs: {
869
- path: '/stream',
870
- responses: { 200: { description: 'Streamed response' } },
871
- },
872
- })
873
- async streamData(context: TRouteContext) {
874
- return context.body(
875
- new ReadableStream({
876
- start(controller) {
877
- controller.enqueue(new TextEncoder().encode('chunk 1\n'));
878
- setTimeout(() => {
879
- controller.enqueue(new TextEncoder().encode('chunk 2\n'));
880
- controller.close();
881
- }, 1000);
882
- },
883
- }),
884
- 200,
885
- { 'Content-Type': 'text/plain' },
886
- );
887
- }
888
- ```
889
-
890
- ### JSX Server-Side Rendering
891
-
892
- ```typescript
893
- this.defineJSXRoute({
894
- configs: {
895
- path: '/profile',
896
- method: 'get',
897
- description: 'User profile page',
898
- authenticate: { strategies: ['jwt'] },
899
- responses: htmlResponse({ description: 'Rendered profile page' }),
900
- },
901
- handler: (context) => {
902
- const user = context.get('auth.current.user');
903
- return context.html(<ProfilePage user={user} />);
904
- },
905
- });
906
- ```
907
-
908
- ### Route Decorators
909
-
910
- | Decorator | Description |
911
- | --- | --- |
912
- | `@controller({ path })` | Class decorator -- registers controller base path |
913
- | `@get({ configs })` | GET route -- method is set automatically |
914
- | `@post({ configs })` | POST route |
915
- | `@put({ configs })` | PUT route |
916
- | `@patch({ configs })` | PATCH route |
917
- | `@del({ configs })` | DELETE route |
918
- | `@api({ configs })` | Generic route -- specify method in configs |
919
-
920
- ### Route Configuration
921
-
922
- ```typescript
923
- interface IAuthRouteConfig extends HonoRouteConfig {
924
- path: string;
925
- method: 'get' | 'post' | 'put' | 'patch' | 'delete';
926
- description?: string;
927
- tags?: string[];
928
-
929
- // Authentication
930
- authenticate?: {
931
- strategies?: ('jwt' | 'basic')[];
932
- mode?: 'any' | 'all';
933
- };
934
-
935
- // Authorization (Casbin RBAC)
936
- authorize?: IAuthorizationSpec | IAuthorizationSpec[];
937
-
938
- // Request schema validation
939
- request?: {
940
- body?: ContentConfig;
941
- query?: ZodSchema;
942
- params?: ZodSchema;
943
- headers?: ZodSchema;
944
- };
945
-
946
- // Response schema
947
- responses: Record<number | string, ResponseConfig>;
948
-
949
- // Additional Hono middleware
950
- middleware?: MiddlewareHandler[];
951
- }
952
- ```
953
-
954
- ### Controller Factory
955
-
956
- Auto-generate a full CRUD controller from an entity definition:
957
-
958
- ```typescript
959
- import { ControllerFactory } from '@venizia/ignis';
960
-
961
- const UserCrudController = ControllerFactory.defineCrudController({
962
- entity: User,
963
- repository: { name: 'UserRepository' },
964
- controller: {
965
- name: 'UserCrudController',
966
- basePath: '/users',
967
- isStrict: {
968
- path: true, // Strict path matching
969
- requestSchema: true, // Strict Zod request validation
970
- },
971
- },
972
- authenticate: { strategies: ['jwt'] },
973
- authorize: { action: 'manage', resource: 'User' },
974
- routes: {
975
- find: { authenticate: { skip: true } }, // Public read -- also skips authorization
976
- findById: { authenticate: { skip: true } }, // Public read
977
- count: { authenticate: { skip: true } }, // Public read
978
- create: {
979
- request: { body: CustomCreateSchema }, // Override request body schema
980
- },
981
- deleteById: {
982
- authorize: { action: 'delete', resource: 'User' }, // Override authorization
983
- },
984
- },
985
- });
986
- ```
987
-
988
- This generates the following endpoints:
989
-
990
- | Method | Path | Description |
991
- | --- | --- | --- |
992
- | `GET` | `/count` | Count records matching where condition |
993
- | `GET` | `/` | Find all records (paginated, with Content-Range header) |
994
- | `GET` | `/{id}` | Find record by ID |
995
- | `GET` | `/find-one` | Find first matching record |
996
- | `POST` | `/` | Create new record |
997
- | `PATCH` | `/{id}` | Update record by ID |
998
- | `PATCH` | `/` | Bulk update matching records |
999
- | `DELETE` | `/{id}` | Delete record by ID |
1000
- | `DELETE` | `/` | Bulk delete matching records |
1001
-
1002
- Each generated endpoint includes:
1003
-
1004
- - OpenAPI schema documentation derived from entity Zod schemas (select, create, update).
1005
- - Conditional count response via `x-request-count` header -- send `x-request-count: false` to get data only without the wrapping `{ count, data }` object.
1006
- - Content-Range header for paginated find results (e.g., `records 0-19/150`).
1007
- - Authentication and authorization middleware from controller-level or route-level config.
1008
- - `X-Response-Count` response header with the count of returned records.
1009
-
1010
- #### Customizing Controller Factory Routes
1011
-
1012
- Per-route auth configuration priority:
1013
-
1014
- 1. If a route has `authenticate: { skip: true }` -- no authentication AND no authorization for that route.
1015
- 2. If a route has `authenticate: { strategies, mode }` -- uses these, overriding controller defaults.
1016
- 3. If a route has `authorize: { skip: true }` -- keeps authentication but skips authorization.
1017
- 4. Otherwise -- uses controller-level `authenticate` and `authorize`.
1018
-
1019
- You can override request/response schemas per route:
1020
-
1021
- ```typescript
1022
- routes: {
1023
- create: {
1024
- request: { body: CustomCreateSchema }, // Custom request body
1025
- response: { schema: CustomResponseSchema }, // Custom response schema
1026
- },
1027
- find: {
1028
- request: { query: CustomFilterQuerySchema }, // Custom query params
1029
- response: { headers: { 'X-Total': { description: 'Total count', schema: { type: 'string' } } } },
1030
- },
1031
- }
1032
- ```
1033
-
1034
- ---
1035
-
1036
- ## Repositories
1037
-
1038
- ### Hierarchy
1039
-
1040
- ```
1041
- AbstractRepository<TDataObject, TPersistObject, TOptions>
1042
- extends BaseHelper (engine-neutral: lazy dataSource/entity resolution, class-keyed
1043
- | @model settings getters -- hiddenFields/defaultWhere/defaultLimit --
1044
- | all CRUD verbs abstract)
1045
- |
1046
- +-- PostgresBaseRepository (+ FilterBuilder, hidden-column query/RETURNING plumbing, drizzle wiring)
1047
- |
1048
- +-- ReadableRepository (read operations only -- write operations throw errors)
1049
- |
1050
- +-- PersistableRepository (+ create, update, delete operations)
1051
- |
1052
- +-- DefaultCRUDRepository (alias -- identical to PersistableRepository)
1053
- +-- SoftDeletableRepository (extends DefaultCRUDRepository -- soft-delete semantics)
1054
- ```
1055
-
1056
- `AbstractRepository` is the single engine-neutral base every connector's hierarchy extends directly -- the search branch (Typesense) re-parents its own `TypesenseBaseRepository` onto it the same way. `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).
1057
-
1058
- ### Defining a Repository
1059
-
1060
- ```typescript
1061
- // Zero boilerplate -- DataSource auto-injected from @repository metadata
1062
- @repository({ model: User, dataSource: PostgresDataSource })
1063
- export class UserRepository extends PersistableRepository<typeof User.schema> {
1064
- // No constructor needed!
1065
- }
1066
-
1067
- // Or with explicit @inject for more control
1068
- @repository({ model: User, dataSource: PostgresDataSource })
1069
- export class UserRepository extends PersistableRepository<typeof User.schema> {
1070
- constructor(
1071
- @inject({ key: 'datasources.PostgresDataSource' }) dataSource: PostgresDataSource,
1072
- ) {
1073
- super(dataSource);
1074
- }
1075
-
1076
- // Custom methods
1077
- async findByEmail(email: string) {
1078
- return this.findOne({ filter: { where: { email } } });
1079
- }
1080
-
1081
- async findActiveUsers() {
1082
- return this.find({
1083
- filter: {
1084
- where: { status: 'active' },
1085
- order: ['createdAt DESC'],
1086
- },
1087
- });
1088
- }
1089
- }
1090
- ```
1091
-
1092
- **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.
1093
-
1094
- ### Read Operations
1095
-
1096
- #### `count()` -- Count Records
1097
-
1098
- ```typescript
1099
- // Simple count
1100
- const { count } = await repository.count({ where: { status: 'active' } });
1101
-
1102
- // Count with complex conditions
1103
- const { count } = await repository.count({
1104
- where: {
1105
- and: [
1106
- { role: { inq: ['admin', 'moderator'] } },
1107
- { createdAt: { gte: new Date('2024-01-01') } },
1108
- { or: [{ isVerified: true }, { score: { gt: 100 } }] },
1109
- ],
1110
- },
1111
- });
1112
-
1113
- // Count within a transaction
1114
- const { count } = await repository.count({
1115
- where: { status: 'pending' },
1116
- options: { transaction: tx },
1117
- });
1118
- ```
1119
-
1120
- #### `existsWith()` -- Check Existence
1121
-
1122
- ```typescript
1123
- const emailTaken = await repository.existsWith({
1124
- where: { email: 'john@example.com' },
1125
- });
1126
-
1127
- if (emailTaken) {
1128
- throw new Error('Email already in use');
1129
- }
1130
- ```
1131
-
1132
- #### `find()` -- Find All Records
1133
-
1134
- ```typescript
1135
- // Basic find with filter
1136
- const users = await repository.find({
1137
- filter: {
1138
- where: { status: 'active' },
1139
- fields: ['id', 'name', 'email'],
1140
- order: ['createdAt DESC'],
1141
- limit: 20,
1142
- skip: 0,
1143
- },
1144
- });
1145
-
1146
- // Find with pagination range info
1147
- const { data, range } = await repository.find({
1148
- filter: { where: { status: 'active' }, limit: 20, skip: 40 },
1149
- options: { shouldQueryRange: true },
1150
- });
1151
- // data = User[] (the 20 records)
1152
- // range = { start: 40, end: 59, total: 150 }
1153
-
1154
- // Find with relation inclusion (uses Query API)
1155
- const usersWithPosts = await repository.find({
1156
- filter: {
1157
- where: { isActive: true },
1158
- include: [
1159
- { relation: 'posts', scope: { where: { isPublished: true }, limit: 5 } },
1160
- ],
1161
- },
1162
- });
1163
-
1164
- // Find all (bypass default filter for admin views)
1165
- const allUsers = await repository.find({
1166
- filter: {},
1167
- options: { shouldSkipDefaultFilter: true },
1168
- });
1169
-
1170
- // Find with transaction
1171
- const users = await repository.find({
1172
- filter: { where: { batchId: currentBatch } },
1173
- options: { transaction: tx },
1174
- });
1175
-
1176
- // Find with row-level locking (requires a transaction; incompatible with include/fields)
1177
- const users = await repository.find({
1178
- filter: { where: { status: 'active' } },
1179
- options: { transaction: tx, lock: { strength: 'update' } },
1180
- });
1181
- ```
1182
-
1183
- #### `findOne()` vs `findById()` -- Differences
1184
-
1185
- `findOne()` accepts a full filter with `where`, `fields`, `include`, and `order`. It returns the first matching record:
1186
-
1187
- ```typescript
1188
- const user = await repository.findOne({
1189
- filter: {
1190
- where: { email: 'john@example.com' },
1191
- fields: ['id', 'name', 'email'],
1192
- include: [{ relation: 'profile' }],
1193
- },
1194
- });
1195
- // Returns User | null
1196
- ```
1197
-
1198
- `findById()` is a convenience wrapper around `findOne()` that automatically sets `where: { id }`. It accepts an optional filter **without** the `where` clause:
1199
-
1200
- ```typescript
1201
- const user = await repository.findById({
1202
- id: 42,
1203
- filter: {
1204
- fields: ['id', 'name', 'email'],
1205
- include: [{ relation: 'posts' }],
1206
- },
1207
- });
1208
- // Returns User | null
1209
- // Equivalent to: findOne({ filter: { where: { id: 42 }, fields: [...], include: [...] } })
1210
- ```
1211
-
1212
- ### Write Operations
1213
-
1214
- #### `create()` -- Create Single Record
1215
-
1216
- ```typescript
1217
- // Create and return the created record (default: shouldReturn = true)
1218
- const { count, data } = await repository.create({
1219
- data: { username: 'john', email: 'john@example.com', role: 'user' },
1220
- });
1221
- // count = 1, data = { id: 1, username: 'john', ... }
1222
-
1223
- // Create without returning data (faster -- skips RETURNING clause)
1224
- const { count } = await repository.create({
1225
- data: { username: 'john', email: 'john@example.com' },
1226
- options: { shouldReturn: false },
1227
- });
1228
- // count = 1, data = null
1229
-
1230
- // Create within a transaction
1231
- const { data: user } = await repository.create({
1232
- data: { username: 'john', email: 'john@example.com' },
1233
- options: { transaction: tx },
1234
- });
1235
- ```
1236
-
1237
- #### `createAll()` -- Bulk Create
1238
-
1239
- ```typescript
1240
- // Bulk create and return all records
1241
- const { count, data } = await repository.createAll({
1242
- data: [
1243
- { username: 'john', email: 'john@example.com' },
1244
- { username: 'jane', email: 'jane@example.com' },
1245
- { username: 'bob', email: 'bob@example.com' },
1246
- ],
1247
- });
1248
- // count = 3, data = [{ id: 1, ... }, { id: 2, ... }, { id: 3, ... }]
1249
-
1250
- // Bulk create without returning (faster for large inserts)
1251
- const { count } = await repository.createAll({
1252
- data: largeDataArray,
1253
- options: { shouldReturn: false },
1254
- });
1255
- ```
1256
-
1257
- #### `updateById()` -- Update Single Record
1258
-
1259
- ```typescript
1260
- // Update by ID and return the updated record
1261
- const { count, data } = await repository.updateById({
1262
- id: 42,
1263
- data: { email: 'new@example.com', status: 'verified' },
1264
- });
1265
- // count = 1, data = { id: 42, email: 'new@example.com', ... }
1266
-
1267
- // Update JSON fields using dot notation
1268
- const { data } = await repository.updateById({
1269
- id: 42,
1270
- data: {
1271
- 'metadata.theme': 'dark',
1272
- 'metadata.notifications.email': false,
1273
- },
1274
- });
1275
- ```
1276
-
1277
- #### `updateAll()` / `updateBy()` -- Bulk Update
1278
-
1279
- ```typescript
1280
- // Update all matching records
1281
- const { count, data } = await repository.updateAll({
1282
- data: { status: 'inactive' },
1283
- where: { lastLoginAt: { lt: new Date('2024-01-01') } },
1284
- });
1285
- // count = 25, data = [...25 updated records...]
1286
-
1287
- // updateBy is an alias for updateAll
1288
- const { count } = await repository.updateBy({
1289
- data: { isNotified: true },
1290
- where: { role: 'subscriber' },
1291
- options: { shouldReturn: false },
1292
- });
1293
-
1294
- // SAFETY: Empty where throws an error to prevent accidental mass updates
1295
- // Use force: true to explicitly allow it
1296
- const { count } = await repository.updateAll({
1297
- data: { version: 2 },
1298
- where: {},
1299
- options: { force: true },
1300
- });
1301
- ```
1302
-
1303
- ### Delete Operations
1304
-
1305
- ```typescript
1306
- // Delete by ID (returns deleted record)
1307
- const { count, data } = await repository.deleteById({ id: 42 });
1308
- // count = 1, data = { id: 42, username: 'john', ... }
1309
-
1310
- // Delete all matching records
1311
- const { count, data } = await repository.deleteAll({
1312
- where: { status: 'inactive' },
1313
- });
1314
-
1315
- // deleteBy is an alias for deleteAll
1316
- const { count } = await repository.deleteBy({
1317
- where: { expiresAt: { lt: new Date() } },
1318
- options: { shouldReturn: false },
1319
- });
1320
-
1321
- // SAFETY: Empty where throws an error. Use force: true to allow.
1322
- const { count } = await repository.deleteAll({
1323
- where: {},
1324
- options: { force: true, shouldReturn: false },
1325
- });
1326
- ```
1327
-
1328
- ### Filter System
1329
-
1330
- ```typescript
1331
- interface TFilter<T> {
1332
- where?: TWhere<T>; // Query conditions
1333
- fields?: TFields; // Column selection
1334
- include?: TInclusion[]; // Relation loading
1335
- order?: string[]; // Sorting (e.g., ['createdAt DESC', 'name ASC'])
1336
- limit?: number; // Max results (default: 10)
1337
- skip?: number; // Offset
1338
- offset?: number; // Fallback for skip -- if both are set, skip wins
1339
- }
1340
- ```
1341
-
1342
- #### Where Operators
1343
-
1344
- Comparison (`eq`/`neq`/`gt`/`gte`/`lt`/`lte`), pattern matching (`like`/`ilike`/`regexp` and negations), array/set (`inq`/`nin`/`between`/`notBetween`), null checks (`is`/`isn`), logical (`and`/`or`), and PostgreSQL array-column operators (`contains`/`containedBy`/`overlaps`) are all supported. JSON path queries (dot notation into `json`/`jsonb` columns, with automatic numeric casting for comparison operators) and JSON path sorting work the same way. Full operator tables: [Filter System Quick Reference](https://github.com/VENIZIA-AI/ignis/blob/main/docs/wiki/content/references/base/filter-system/quick-reference.md).
1345
-
1346
- ```typescript
1347
- // Comparison + pattern matching
1348
- const users = await repository.find({
1349
- filter: { where: { age: { gte: 18 }, email: { ilike: '%@gmail.com' } } },
1350
- });
1351
-
1352
- // Array/set + logical
1353
- const users = await repository.find({
1354
- filter: { where: { and: [{ status: { inq: ['active', 'pending'] } }, { role: { neq: 'guest' } }] } },
1355
- });
1356
-
1357
- // JSON path query (dot notation into a jsonb column)
1358
- const users = await repository.find({
1359
- filter: { where: { 'metadata.score': { gt: 50, lte: 100 } } },
1360
- });
1361
- ```
1362
-
1363
- #### Field Selection
1364
-
1365
- ```typescript
1366
- // Array format -- include only these columns
1367
- const users = await repository.find({
1368
- filter: { fields: ['id', 'name', 'email'] },
1369
- });
1370
-
1371
- // Object format -- include/exclude
1372
- const users = await repository.find({
1373
- filter: { fields: { id: true, name: true, password: false } },
1374
- });
1375
- ```
1376
-
1377
- #### Relation Inclusion
1378
-
1379
- ```typescript
1380
- // Simple inclusion
1381
- const users = await repository.find({
1382
- filter: {
1383
- include: [{ relation: 'posts' }],
1384
- },
1385
- });
1386
-
1387
- // With nested filter (scope)
1388
- const users = await repository.find({
1389
- filter: {
1390
- include: [{
1391
- relation: 'posts',
1392
- scope: {
1393
- where: { isPublished: true },
1394
- limit: 5,
1395
- order: ['createdAt DESC'],
1396
- include: [{ relation: 'comments' }], // Nested relations
1397
- },
1398
- }],
1399
- },
1400
- });
1401
-
1402
- // Skip default filter on a specific relation
1403
- const users = await repository.find({
1404
- filter: {
1405
- include: [{
1406
- relation: 'archivedPosts',
1407
- shouldSkipDefaultFilter: true, // Show soft-deleted posts
1408
- }],
1409
- },
1410
- });
1411
- ```
1412
-
1413
- 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.
1414
-
1415
- ### `shouldQueryRange` -- Range Object
1416
-
1417
- When `shouldQueryRange: true` is passed to `find()`, the method runs both the data fetch and a count query in parallel, then returns:
1418
-
1419
- ```typescript
1420
- const result = await repository.find({
1421
- filter: { where: { status: 'active' }, limit: 10, skip: 20 },
1422
- options: { shouldQueryRange: true },
1423
- });
1424
-
1425
- // result.data = User[] (the 10 records)
1426
- // result.range = { start: 20, end: 29, total: 150 }
1427
- ```
1428
-
1429
- This follows the HTTP Content-Range header standard. The ControllerFactory uses this to set `Content-Range: records 20-29/150` headers.
1430
-
1431
- ### `shouldSkipDefaultFilter` -- When and Why
1432
-
1433
- The `shouldSkipDefaultFilter` option bypasses the model's `defaultFilter`. Common use cases:
1434
-
1435
- ```typescript
1436
- // Admin panel showing all records (including soft-deleted)
1437
- const allUsers = await repository.find({
1438
- filter: {},
1439
- options: { shouldSkipDefaultFilter: true },
1440
- });
1441
-
1442
- // Data migration or cleanup script
1443
- const deletedUsers = await repository.find({
1444
- filter: { where: { isDeleted: true } },
1445
- options: { shouldSkipDefaultFilter: true },
1446
- });
1447
-
1448
- // Export all data for backup
1449
- const everything = await repository.find({
1450
- filter: {},
1451
- options: { shouldSkipDefaultFilter: true, shouldQueryRange: true },
1452
- });
1453
- ```
1454
-
1455
- ### ExtraOptions
1456
-
1457
- All repository operations accept an `options` parameter. The base shape is `IExtraOptions`:
1458
-
1459
- ```typescript
1460
- interface IExtraOptions {
1461
- transaction?: ITransaction; // Use within a transaction
1462
- log?: { use: boolean; level?: TLogLevel }; // Enable operation logging (write operations only)
1463
- shouldSkipDefaultFilter?: boolean; // Bypass model's default filter
1464
- lock?: { // Row-level locking. Requires a transaction; incompatible
1465
- strength: 'update' | 'no key update' | 'share' | 'key share'; // with include/fields (Query API)
1466
- config?: { noWait?: true } | { skipLocked?: true };
1467
- };
1468
- }
1469
- ```
1470
-
1471
- `shouldReturn` and `shouldQueryRange` are **not** part of `IExtraOptions` -- they only exist as ad-hoc intersections on the specific methods that support them: `shouldReturn` on `create`/`createAll`/`updateById`/`updateAll`/`deleteById`/`deleteAll` (`options: IExtraOptions & { shouldReturn?: boolean }`), and `shouldQueryRange` only on `find` (`options: IExtraOptions & { shouldQueryRange?: boolean }`). `count`/`existsWith` accept neither.
1472
-
1473
- `log: { use: true }` is only honored by write operations (`create`/`updateById`/`deleteById`/etc.) -- read operations (`find`/`findOne`/`count`/`existsWith`) never inspect it.
1474
-
1475
- ### Hidden Fields and Default Filter
1476
-
1477
- This behavior lives directly on the repository hierarchy now, not in separate mixins: `AbstractRepository` (`src/base/repositories/core/`) exposes the class-keyed `@model` settings as protected getters -- `hiddenFields`, `defaultWhere`, `defaultLimit` -- resolved once per entity class and memoized; `PostgresBaseRepository` (`src/connectors/postgres/repositories/core/`) is where those getters actually get applied to SQL, via its own `getHiddenProperties()`/`getVisibleProperties()`/`buildQuery()`/`applyDefaultFilter()`. The two mixins this section used to describe (`FieldsVisibilityMixin`, `DefaultFilterMixin`, under `src/connectors/postgres/repositories/mixins/`) are legacy -- no longer composed onto any repository class -- and are not exported from any barrel.
1478
-
1479
- #### Hidden fields
1480
-
1481
- Automatically excludes properties listed in `@model({ settings: { hiddenProperties } })` from all query results at the SQL level -- not post-processing:
1482
-
1483
- ```typescript
1484
- @model({
1485
- type: 'entity',
1486
- settings: { hiddenProperties: ['password', 'secretToken'] },
1487
- })
1488
- export class User extends BasePostgresEntity<typeof User.schema> { ... }
1489
-
1490
- // All repository queries automatically exclude 'password' and 'secretToken'
1491
- const user = await userRepository.findById({ id: 1 });
1492
- // user.password === undefined (never selected from DB)
1493
-
1494
- // Hidden fields are excluded from:
1495
- // - find() / findOne() / findById() SELECT queries
1496
- // - create() RETURNING clauses
1497
- // - updateById() / updateAll() RETURNING clauses
1498
- // - deleteById() / deleteAll() RETURNING clauses
1499
- // - Included relation queries (applied recursively)
1500
- ```
1501
-
1502
- `PostgresBaseRepository.getVisibleProperties()` caches the visible property set for performance. It computes it once from the schema columns minus hidden properties.
1503
-
1504
- #### Default filter
1505
-
1506
- Automatically applies a default filter to all queries. Common use case -- soft delete:
1507
-
1508
- ```typescript
1509
- @model({
1510
- type: 'entity',
1511
- settings: { defaultFilter: { where: { isDeleted: false } } },
1512
- })
1513
- export class User extends BasePostgresEntity<typeof User.schema> { ... }
1514
-
1515
- // All queries automatically add WHERE is_deleted = false
1516
- const users = await userRepository.find({ filter: {} });
1517
-
1518
- // The default filter merges with user-provided filters:
1519
- const activeAdmins = await userRepository.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 userRepository.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
- ### BasePostgresEntity
1564
-
1565
- All entities extend `BasePostgresEntity` and define a static `schema` using Drizzle's `pgTable`:
1566
-
1567
- ```typescript
1568
- import { model } from '@venizia/ignis';
1569
- import { BasePostgresEntity, TRelationConfig } from '@venizia/ignis/postgres';
1570
- import { pgTable, text, jsonb, boolean } from 'drizzle-orm/pg-core';
1571
-
1572
- @model({
1573
- type: 'entity',
1574
- settings: {
1575
- hiddenProperties: ['password', 'secretToken'],
1576
- defaultFilter: { where: { isDeleted: false } },
1577
- },
1578
- })
1579
- export class User extends BasePostgresEntity<typeof User.schema> {
1580
- static override schema = pgTable('User', {
1581
- ...generateIdColumnDefs({ id: { dataType: 'string' } }),
1582
- ...generateTzColumnDefs({
1583
- deleted: { enable: true, columnName: 'deleted_at', withTimezone: true },
1584
- }),
1585
- ...generateUserAuditColumnDefs(),
1586
- username: text('username').notNull().unique(),
1587
- email: text('email').notNull().unique(),
1588
- password: text('password'),
1589
- secretToken: text('secret_token'),
1590
- role: text('role').default('user'),
1591
- isDeleted: boolean('is_deleted').default(false),
1592
- metadata: jsonb('metadata').$type<{ theme?: string; score?: number }>(),
1593
- });
1594
-
1595
- static override relations = (): TRelationConfig[] => [
1596
- // 'many' side: no fields/references -- relationName points at the 'one' relation declared on Post
1597
- { name: 'posts', type: 'many', schema: Post.schema, metadata: { relationName: 'author' } },
1598
- // 'one' side: fields/references establish the FK, since it's declared on this table
1599
- { name: 'profile', type: 'one', schema: Profile.schema, metadata: { fields: [Profile.schema.userId], references: [User.schema.id] } },
1600
- ];
1601
-
1602
- static override TABLE_NAME = 'User';
1603
- }
1604
- ```
1605
-
1606
- ### @model Decorator
1607
-
1608
- ```typescript
1609
- @model({
1610
- type: 'entity', // Entity type identifier
1611
- settings: {
1612
- hiddenProperties: ['password'], // Excluded from all queries at SQL level
1613
- defaultFilter: { where: { isDeleted: false } }, // Auto-applied to all queries
1614
- },
1615
- })
1616
- ```
1617
-
1618
- When `@model` is applied, it registers the class with the `MetadataRegistry`, extracting:
1619
-
1620
- - The static `schema` (pgTable definition)
1621
- - The static `relations` (relation configuration array or resolver function)
1622
- - The model metadata (type, settings)
1623
-
1624
- ### Schema Generation
1625
-
1626
- `BasePostgresEntity` provides `getSchema()` for Zod schema generation from the Drizzle table using `drizzle-zod`:
1627
-
1628
- ```typescript
1629
- const entity = new User();
1630
-
1631
- entity.getSchema({ type: 'select' }); // Zod schema for SELECT results -- all fields as returned from DB
1632
- entity.getSchema({ type: 'create' }); // Zod schema for INSERT data -- required/optional based on column definitions
1633
- entity.getSchema({ type: 'update' }); // Zod schema for UPDATE data -- all fields optional (partial)
1634
- ```
1635
-
1636
- These schemas are used by `ControllerFactory` to auto-generate OpenAPI documentation. The schema factory is lazily initialized and shared across all BasePostgresEntity instances for performance.
1637
-
1638
- ### Static `relations` Definition
1639
-
1640
- Relations are defined as a static property or function returning an array of `TRelationConfig`:
1641
-
1642
- ```typescript
1643
- static override relations = (): TRelationConfig[] => [
1644
- {
1645
- name: 'posts',
1646
- type: 'many', // RelationTypes.MANY -- no fields/references; relationName points
1647
- schema: Post.schema, // at the 'one' relation declared on the other side (Post)
1648
- metadata: {
1649
- relationName: 'author',
1650
- },
1651
- },
1652
- {
1653
- name: 'profile',
1654
- type: 'one', // RelationTypes.ONE -- the FK-owning side declares fields/references
1655
- schema: Profile.schema,
1656
- metadata: {
1657
- fields: [Profile.schema.userId],
1658
- references: [User.schema.id],
1659
- },
1660
- },
1661
- ];
1662
- ```
1663
-
1664
- These relations are used by the `FilterBuilder` when processing `include` in filters, and by the DataSource's `discoverSchema()` to build Drizzle relation definitions.
1665
-
1666
- ### Enrichers
1667
-
1668
- Column definition helpers that spread common column patterns into a `pgTable` schema:
1669
-
1670
- | Enricher | Adds |
1671
- | --- | --- |
1672
- | `generateIdColumnDefs()` | Primary key column -- auto-increment integer (default), UUID string, big-number, or custom string generator |
1673
- | `generateTzColumnDefs()` | `created_at`/`modified_at` timestamps (with timezone, auto `$onUpdate`), optional `deleted_at` for soft delete |
1674
- | `generateUserAuditColumnDefs()` | `created_by`/`modified_by`, auto-populated from `context.get('audit.user.id')` on create/update |
1675
- | `generatePrincipalColumnDefs()` | Polymorphic discriminator pair (`principal_id`/`principal_type` by default) |
1676
- | `generateDataTypeColumnDefs()` | Multi-type value columns (`data_type` + `n_value`/`t_value`/`b_value`/`j_value`/`bo_value`) for key-value/settings tables |
1677
-
1678
- Full options and generated-column tables: [Models reference](https://github.com/VENIZIA-AI/ignis/blob/main/docs/wiki/content/references/base/models.md).
1679
-
1680
- ---
1681
-
1682
- ## DataSources
1683
-
1684
- ### BasePostgresDataSource
1685
-
1686
- DataSources manage database connections and provide Drizzle connectors:
1687
-
1688
- ```typescript
1689
- abstract class BasePostgresDataSource<Settings, Schema> extends AbstractPostgresDataSource {
1690
- // Implemented by subclass
1691
- abstract configure(): ValueOrPromise<void>;
1692
- abstract getConnectionString(): ValueOrPromise<string>;
1693
-
1694
- // Auto-discovers schema from @repository bindings
1695
- getSchema(): Schema;
1696
-
1697
- // Check if any repositories reference this datasource
1698
- hasDiscoverableModels(): boolean;
1699
-
1700
- // Transaction support -- postgres's own IDatabaseTransactionOptions/IDatabaseTransaction<Schema>,
1701
- // narrowing the neutral ITransactionOptions/ITransaction from AbstractDataSource
1702
- beginTransaction(opts?: IDatabaseTransactionOptions): Promise<IDatabaseTransaction<Schema>>;
1703
- }
1704
- ```
1705
-
1706
- #### Capability Probe
1707
-
1708
- Every datasource -- regardless of connector -- exposes `getCapabilities(): { transactions: boolean }`. `AbstractDataSource` (the engine-neutral root every connector extends) defaults it to `{ transactions: false }`, paired with a `beginTransaction()` default that throws the standardized NotSupported error (`core.not_supported`, HTTP 501). `BasePostgresDataSource` (postgres) is the one connector that overrides both: `getCapabilities()` reports `{ transactions: true }`, and `beginTransaction()` is a real implementation (below) instead of the inherited throw. `TypesenseDataSource` adds no override -- it inherits the neutral NotSupported default from `AbstractDataSource` directly. Check `getCapabilities().transactions` before calling `beginTransaction()` on a datasource whose connector isn't known ahead of time.
1709
-
1710
- ### Complete DataSource Configuration
1711
-
1712
- ```typescript
1713
- @datasource({ driver: 'node-postgres' })
1714
- export class PostgresDataSource extends BasePostgresDataSource<IDSConfigs> {
1715
- constructor() {
1716
- super({
1717
- name: PostgresDataSource.name,
1718
- config: {
1719
- host: process.env.DB_HOST!,
1720
- port: +(process.env.DB_PORT ?? 5432),
1721
- database: process.env.DB_NAME!,
1722
- user: process.env.DB_USER!,
1723
- password: process.env.DB_PASSWORD!,
1724
- // pg Pool options:
1725
- max: 20, // max pool connections
1726
- idleTimeoutMillis: 30000, // close idle clients after 30s
1727
- connectionTimeoutMillis: 5000, // timeout connecting after 5s
1728
- },
1729
- });
1730
- }
1731
-
1732
- override configure(): ValueOrPromise<void> {
1733
- const schema = this.getSchema(); // Auto-discovers from repositories
1734
- this.pool = new Pool(this.settings);
1735
- this.connector = drizzle({ client: this.pool, schema });
1736
- }
1737
-
1738
- override getConnectionString() {
1739
- const { host, port, user, password, database } = this.settings;
1740
- return `postgresql://${user}:${password}@${host}:${port}/${database}`;
1741
- }
1742
- }
1743
- ```
1744
-
1745
- ### Schema Auto-Discovery
1746
-
1747
- DataSources do not need manual schema configuration. When `getSchema()` is called during `configure()`, the DataSource:
1748
-
1749
- 1. Queries the `MetadataRegistry` for all `@repository` bindings that reference this DataSource class.
1750
- 2. For each binding, extracts the model's static `schema` (pgTable) and `relations`.
1751
- 3. Combines them into a single schema object: `{ User: User.schema, Post: Post.schema, ...relations }`.
1752
- 4. Caches the result.
1753
-
1754
- This means adding a new model + repository automatically makes it available to all queries without touching the DataSource code.
1755
-
1756
- ### Transaction Deep Dive
1757
-
1758
- ```typescript
1759
- const tx = await dataSource.beginTransaction({
1760
- isolationLevel: 'READ COMMITTED', // default
1761
- });
1762
-
1763
- try {
1764
- await userRepository.create({
1765
- data: { username: 'john', email: 'john@example.com' },
1766
- options: { transaction: tx },
1767
- });
1768
-
1769
- await auditRepository.create({
1770
- data: { action: 'user_created', userId: newUser.id },
1771
- options: { transaction: tx },
1772
- });
1773
-
1774
- await tx.commit();
1775
- } catch (error) {
1776
- await tx.rollback();
1777
- throw error;
1778
- }
1779
- ```
1780
-
1781
- `beginTransaction()` acquires a dedicated `PoolClient` and returns an `IDatabaseTransaction<Schema>` (`connector`, `commit()`, `rollback()`, `isolationLevel`, `isActive`); passing `{ transaction: tx }` to a repository method routes it through that dedicated connector instead of the DataSource's shared pool, and `commit()`/`rollback()` always release the client back to the pool. Full internals: [Transactions guide](https://github.com/VENIZIA-AI/ignis/blob/main/docs/wiki/content/guides/core-concepts/persistent/transactions.md).
1782
-
1783
- #### Isolation Levels
1784
-
1785
- | Level | Constant | When to Use |
1786
- | --- | --- | --- |
1787
- | `READ COMMITTED` | `IsolationLevels.READ_COMMITTED` | Default. Each statement sees only data committed before it began. Sufficient for most CRUD operations. |
1788
- | `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). |
1789
- | `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. |
1790
-
1791
- #### Connection Release
1792
-
1793
- 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.
1794
-
1795
- ---
1796
-
1797
- ## Search
1798
-
1799
- IGNIS has a second, engine-neutral branch for search-only entities backed by Typesense instead of Postgres. It mirrors the SQL branch -- same `@model`/`@repository` decorators, same `TFilter`/where-operator vocabulary, same hidden-field/default-filter/default-limit behavior -- but returns a slightly different envelope in a few places (see below).
1800
-
1801
- ### Search Entities
1802
-
1803
- Search entities extend `BaseSearchEntity` and define a static `definition` via `defineSearchCollection` + the `field` DSL, instead of Drizzle's `pgTable`. Same as `BasePostgresEntity`, apply `@model` so `hiddenProperties`/`defaultFilter`/`defaultLimit` are registered:
1804
-
1805
- ```typescript
1806
- import { model } from '@venizia/ignis';
1807
- import { BaseSearchEntity, defineSearchCollection, field } from '@venizia/ignis/typesense';
1808
-
1809
- @model({
1810
- type: 'entity',
1811
- settings: { hiddenProperties: ['internalNotes'], defaultLimit: 20 },
1812
- })
1813
- export class Product extends BaseSearchEntity {
1814
- static override definition = defineSearchCollection({
1815
- name: 'products',
1816
- fields: [
1817
- field.id(),
1818
- field.string('name', { searchable: true, sortable: true }),
1819
- field.number('price', { filterable: true, sortable: true }),
1820
- field.string('internalNotes', { optional: true }),
1821
- ],
1822
- defaultSort: 'price',
1823
- });
1824
- }
1825
- ```
1826
-
1827
- `field.id()` is optional -- `defineSearchCollection` prepends it automatically when missing. `defaultSort` must reference a `number` field; Typesense's `default_sorting_field` only accepts a scalar numeric field, never a string or array.
1828
-
1829
- ### Search DataSources
1830
-
1831
- `TypesenseDataSource` is a `BaseSearchDataSource` subclass, imported from a dedicated sub-path so the root `@venizia/ignis` barrel never pulls in the `typesense` client:
1832
-
1833
- ```typescript
1834
- import { DataSourceDrivers, datasource } from '@venizia/ignis';
1835
- import { TypesenseDataSource } from '@venizia/ignis/typesense';
1836
-
1837
- @datasource({ driver: DataSourceDrivers.TYPESENSE })
1838
- export class ProductSearchDataSource extends TypesenseDataSource {
1839
- constructor() {
1840
- super({
1841
- name: ProductSearchDataSource.name,
1842
- config: {
1843
- nodes: [{ host: process.env.TYPESENSE_HOST!, port: +(process.env.TYPESENSE_PORT ?? 8108) }],
1844
- apiKey: process.env.TYPESENSE_API_KEY!,
1845
- },
1846
- });
1847
- }
1848
- }
1849
- ```
1850
-
1851
- `typesense` is an optional peer dependency -- install it yourself (`bun add typesense`) if you use the search branch.
1852
-
1853
- Same as the postgres branch, collections are discovered from `@repository` bindings and auto-provisioned (via `ensureCollection()`, additive-only) when the DataSource's `configure()` runs during boot. Opt out with:
1854
-
1855
- - `autoProvision: false` in the `super({ ... })` call -- skips provisioning, discovery still runs.
1856
- - `@datasource({ autoDiscovery: false })` -- skips discovery entirely (`getSchema()` returns `{}`).
1857
-
1858
- `getClient()` returns the raw typesense `Client`, an escape hatch symmetric to the SQL branch's `pg.Pool` access:
1859
-
1860
- ```typescript
1861
- const client = productSearchDataSource.getClient();
1862
- ```
1863
-
1864
- ### Search Repositories
1865
-
1866
- Same tiered hierarchy as the SQL branch, over Typesense instead of Postgres:
1867
-
1868
- ```
1869
- ReadableSearchRepository (read operations only)
1870
- |
1871
- +-- PersistableSearchRepository (+ create, update)
1872
- |
1873
- +-- DefaultSearchRepository (+ delete)
1874
- ```
1875
-
1876
- `TSearchDocument<typeof X.schema>` derives the document type straight from the collection definition -- same idea as postgres's `typeof User.schema` inference, no hand-written interface needed:
1877
-
1878
- ```typescript
1879
- import { repository } from '@venizia/ignis';
1880
- import { DefaultSearchRepository, TSearchDocument } from '@venizia/ignis/typesense';
1881
-
1882
- type ProductDocument = TSearchDocument<typeof Product.schema>;
1883
- // -> { id: string; name: string; price: number; internalNotes?: string }
1884
-
1885
- @repository({ model: Product, dataSource: ProductSearchDataSource })
1886
- export class ProductSearchRepository extends DefaultSearchRepository<ProductDocument> {
1887
- // No constructor needed -- dataSource auto-injected from @repository metadata
1888
- }
1889
- ```
1890
-
1891
- `TSearchDocument` only resolves field-level types when the collection is captured through `defineSearchCollection`'s `const` type param -- true as long as you call it directly on a `static schema = defineSearchCollection({ ... })` assignment, as above. `id` is always inferred as a required `string`; fields marked `{ optional: true }` become optional properties, everything else is required.
1892
-
1893
- `@repository`, `TFilter`, and the where operators (`eq`, `gt`, `inq`, `and`/`or`, ...) work the same as the SQL branch -- `TFilter`/`TWhere` is translated into Typesense search params by `TypesenseQueryDialect`. Operators that can't be expressed as a Typesense `filter_by` throw instead of silently degrading:
1894
-
1895
- - `like` / `ilike` (and their negations)
1896
- - JSON-path fields (e.g. `'metadata.score'`)
1897
- - `include` (relations)
1898
-
1899
- Reach for the repository's `search()` method when you need one of these -- it's a raw passthrough to the driver (no dialect translation, no default filter applied).
1900
-
1901
- `hiddenProperties`, `defaultFilter`, and `defaultLimit` from `@model` settings are honored the same way they are for the SQL branch: hidden fields become Typesense `exclude_fields`, `defaultFilter` is AND-merged into every query, and an omitted `limit` falls back to `defaultLimit` then `DEFAULT_LIMIT` (10).
1902
-
1903
- Note "the same way" covers hidden-fields/limit fallback, not the `where` merge policy: the search branch (like memory) always AND-merges `defaultFilter.where` with the user's `where`, while Postgres deep-merges with the user filter winning key-by-key (`merge({}, defaultWhere, userWhere)`, `filter.ts`) -- see the memory connector section below for the concrete difference.
1904
-
1905
- ### Envelope Differences vs SQL
1906
-
1907
- `create()`/`createAll()` are overloaded by `shouldReturn` the same way on both branches, and every write verb now returns `{ count, data }` on both branches too -- but Typesense has no `RETURNING` equivalent, so a few return shapes and constraints still differ underneath that shared envelope:
1908
-
1909
- | Aspect | SQL | Search |
1910
- | --- | --- | --- |
1911
- | `deleteById()` `data` | The deleted row, straight from `RETURNING` | Populated only when the model has a `defaultFilter` -- its guard read (a `findById()` done to check the row isn't excluded) is the only document Typesense ever hands back. No `defaultFilter` means no guard read, so `data` is `null` even with `shouldReturn: true` |
1912
- | `deleteAll()` / `deleteBy()` `data` | The deleted rows via `RETURNING`, always in lockstep with `count` | A `find()` snapshot taken *before* the delete, capped at `defaultLimit`/`DEFAULT_LIMIT` (10) like any other read -- `count` (the engine's real deleted-row count) can exceed `data.length` when more rows matched than the snapshot's page size. Truncating (no effective `where`) reports `{ count: 0, data: [] }` -- Typesense's truncate reports no per-document count at all |
1913
- | `updateAll()` / `updateBy()` `data` | The updated rows via `RETURNING`, always in lockstep with `count` | A `find()` snapshot taken *after* the update, subject to the same pagination cap as `deleteAll()` -- can likewise diverge from `count` |
1914
- | `skip` | Any offset | Must be a multiple of `limit` -- Typesense paginates by page, not row offset |
1915
- | Transactions / row-level locks | `beginTransaction()` / `{ transaction }`, `{ lock }` in options | Neither is supported -- both throw the standardized NotSupported error |
1916
-
1917
- ### Adding a Search Engine
1918
-
1919
- A new engine is one folder under `src/connectors/<engine>/` -- `DataSource`, `Driver`, a paired `QueryDialect` (never mixed with another engine's driver), a compiler (neutral DSL -> engine schema), a repository tier re-parented onto the shared `AbstractRepository`, and an internal error classifier -- sub-path exported (`@venizia/ignis/<engine>`) with the client as an optional peer dependency, the same convention `typesense/` follows today. Full contract (fixed `ISearchDriver` verb set, escape-hatch extras, transaction posture): [Connectors reference](https://github.com/VENIZIA-AI/ignis/blob/main/docs/wiki/content/references/base/connectors.md).
1920
-
1921
- ---
1922
-
1923
- ## Memory Connector
1924
-
1925
- `MemoryDataSource` + `MemoryRepository` (`src/connectors/memory/`) are a zero-dependency, Map-backed engine for prototyping and tests -- the same role LoopBack 4's memory connector plays. No client library, no network, no peer dependency. Import it from the root (`@venizia/ignis`) or from its explicit sub-path (`@venizia/ignis/memory`) - both doors resolve to the same modules. Only optional-peer connectors (typesense) are sub-path-only:
1926
-
1927
- ```typescript
1928
- import {
1929
- AbstractEntity,
1930
- SchemaTypes,
1931
- TSchemaType,
1932
- model,
1933
- repository,
1934
- } from '@venizia/ignis';
1935
- import { MemoryDataSource, MemoryRepository } from '@venizia/ignis/memory';
1936
- import { z } from '@hono/zod-openapi';
1937
-
1938
- const ProductSelectSchema = z.object({ id: z.string(), title: z.string(), price: z.number() });
1939
- type ProductDocument = z.infer<typeof ProductSelectSchema>;
1940
-
1941
- @model({ type: 'entity', settings: { defaultLimit: 20 } })
1942
- class Product extends AbstractEntity {
1943
- static readonly COLLECTION_NAME = 'products';
1944
-
1945
- constructor() {
1946
- super({ name: Product.COLLECTION_NAME });
1947
- }
1948
-
1949
- getSchema(opts: { type: TSchemaType }) {
1950
- switch (opts.type) {
1951
- case SchemaTypes.SELECT:
1952
- return ProductSelectSchema;
1953
- default:
1954
- return ProductSelectSchema;
1955
- }
1956
- }
1957
- }
1958
-
1959
- class ProductDataSource extends MemoryDataSource {
1960
- constructor() {
1961
- super({ name: ProductDataSource.name });
1962
- }
1963
- }
1964
-
1965
- @repository({ model: Product, dataSource: ProductDataSource })
1966
- class ProductRepository extends MemoryRepository<ProductDocument> {
1967
- // No constructor needed -- dataSource auto-injected from @repository metadata
1968
- }
1969
-
1970
- const productRepository = new ProductRepository(new ProductDataSource());
1971
- const products = await productRepository.find({ filter: { where: { price: { gt: 10 } }, order: ['price DESC'] } });
1972
- ```
1973
-
1974
- A static `COLLECTION_NAME` on the model is this connector's discoverable-model convention -- the equivalent of postgres's static `schema` or the search branch's static `definition`. `MemoryRepository` is a single, untiered class implementing every `AbstractRepository` verb (no Readable/Persistable/DefaultCRUD split -- there's no connection/dialect plumbing to layer progressively), supporting equality, comparison (`gt`/`gte`/`lt`/`lte`/`between`), pattern (`like`/`ilike` via anchored `RegExp`), array (`inq`/`nin`), and logical (`and`/`or`) operators, plus `order`/`skip`/`limit`/`fields`. Operators with no faithful plain-JS meaning (`is`/`isn`, `regexp`/`iregexp`, the PostgreSQL-only array operators, `include`) throw instead of guessing.
1975
-
1976
- `hiddenProperties`, `defaultFilter`, and `defaultLimit` from `@model` settings are honored the same way every connector honors them, with the same AND-merge default-filter policy as the search branch (narrows rather than overrides, unlike postgres's key-by-key deep merge). No transactions or row locks -- both reject with the standardized NotSupported error. Ids default to `crypto.randomUUID()` when a document is created without one.
1977
-
1978
- Full operator matrix and internals: [Memory Connector guide](https://github.com/VENIZIA-AI/ignis/blob/main/docs/wiki/content/guides/core-concepts/persistent/memory-connector.md).
1979
-
1980
- ---
1981
-
1982
- ## Services
1983
-
1984
- Services encapsulate business logic and are registered in the DI container:
1985
-
1986
- ```typescript
1987
- import { BaseService, inject } from '@venizia/ignis';
1988
-
1989
- export class UserService extends BaseService {
1990
- constructor(
1991
- @inject({ key: 'repositories.UserRepository' }) private userRepository: UserRepository,
1992
- @inject({ key: 'repositories.AuditRepository' }) private auditRepository: AuditRepository,
1993
- ) {
1994
- super({ scope: UserService.name });
1995
- }
1996
-
1997
- async createUser(data: CreateUserInput) {
1998
- const tx = await this.userRepository.dataSource.beginTransaction();
1999
- try {
2000
- const { data: user } = await this.userRepository.create({
2001
- data,
2002
- options: { transaction: tx },
2003
- });
2004
-
2005
- await this.auditRepository.create({
2006
- data: { action: 'user_created', userId: user.id },
2007
- options: { transaction: tx },
2008
- });
2009
-
2010
- await tx.commit();
2011
- return user;
2012
- } catch (error) {
2013
- await tx.rollback();
2014
- throw error;
2015
- }
2016
- }
2017
-
2018
- async deactivateInactiveUsers() {
2019
- const cutoff = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); // 90 days
2020
- const { count } = await this.userRepository.updateBy({
2021
- data: { status: 'inactive' },
2022
- where: { lastLoginAt: { lt: cutoff }, status: 'active' },
2023
- });
2024
- this.logger.info('Deactivated %d inactive users', count);
2025
- return { count };
2026
- }
2027
- }
2028
- ```
2029
-
2030
- Register in the application:
2031
-
2032
- ```typescript
2033
- this.service(UserService);
2034
- ```
2035
-
2036
- `BaseService` extends `BaseHelper`, providing a scoped logger instance (`this.logger`).
2037
-
2038
- ---
108
+ ```bash
109
+ bun run src/index.ts
110
+ curl http://localhost:3000/api/hello # {"message":"Hello from IGNIS!"}
111
+ ```
112
+
113
+ `getAppInfo`, `preConfigure`, `postConfigure`, `staticConfigure` and `setupMiddlewares` are abstract -
114
+ implement all five, even when empty.
115
+
116
+ ## The surface
117
+
118
+ | Layer | Extend / use | Docs |
119
+ | :--- | :--- | :--- |
120
+ | Application | `BaseApplication` - lifecycle, config, `component()`, `controller()`, `start()` | [Application](https://ignis.venizia.ai/references/base/application) |
121
+ | REST controller | `BaseRestController` - decorated routes, or `bindRoute` / `defineRoute` | [Controllers](https://ignis.venizia.ai/references/base/controllers) |
122
+ | gRPC controller | `BaseGrpcController` with `@rpc` - pair it with `GrpcComponent` | [Controllers](https://ignis.venizia.ai/references/base/controllers) |
123
+ | CRUD controller | `ControllerFactory.defineCrudController({ controller, entity, routes })` | [Controllers](https://ignis.venizia.ai/references/base/controllers) |
124
+ | Service | `BaseService` | [References](https://ignis.venizia.ai/references/) |
125
+ | Repository | `DefaultRelationalRepository`, or the readable / persistable / soft-deletable variants | [Repositories](https://ignis.venizia.ai/references/base/repositories/) |
126
+ | Model | `BaseRelationalEntity` over a Drizzle `pgTable` | [Repositories](https://ignis.venizia.ai/references/base/repositories/) |
127
+ | DataSource | `BaseRelationalDataSource` | [Repositories](https://ignis.venizia.ai/references/base/repositories/) |
128
+ | Component | `BaseComponent` - bundle bindings, controllers and middlewares as one unit | [Components](https://ignis.venizia.ai/extensions/components/) |
129
+ | Provider | `BaseProvider<T>` - a lazily resolved binding value | [DI](https://ignis.venizia.ai/references/base/dependency-injection) |
130
+
131
+ Decorators, all from the root barrel:
132
+
133
+ | Decorator | Target | Purpose |
134
+ | :--- | :--- | :--- |
135
+ | `@controller({ path })` | class | Register a controller under a base path |
136
+ | `@get` `@post` `@put` `@patch` `@del` `@api` | method | Declare a REST route from a Zod-typed config |
137
+ | `@rpc` | method | Declare a gRPC method |
138
+ | `@model` `@datasource` `@repository` | class | Register persistence classes for boot discovery |
139
+ | `@inject({ key, isOptional })` | constructor parameter | Resolve a binding by namespaced key |
140
+
141
+ > [!WARNING]
142
+ > **Every** constructor parameter of a container-instantiated class must carry `@inject`. Mixing
143
+ > decorated and undecorated parameters is refused at boot - the container has no channel to supply an
144
+ > undecorated one. Options a controller needs belong in `super({ scope })`.
145
+
146
+ `BaseRelationalEntity`, `DefaultRelationalRepository` and `BaseRelationalDataSource` are the
147
+ canonical, paradigm-family names. `BasePostgresEntity`, `DefaultCRUDRepository`,
148
+ `BasePostgresDataSource`, `BaseEntity` and `BaseDataSource` still resolve to the same classes as
149
+ back-compat aliases - prefer the family names in new code.
2039
150
 
2040
151
  ## Components
2041
152
 
2042
- Components are self-contained modules that register controllers, services, bindings, and middleware. They extend `BaseComponent` and participate in the application lifecycle.
2043
-
2044
- ### Built-in Components
2045
-
2046
- | Component | Import | Description |
2047
- | --- | --- | --- |
2048
- | **HealthCheckComponent** | `@venizia/ignis` | Health check endpoints (`GET /health`, `POST /health/ping`) |
2049
- | **SwaggerComponent** | `@venizia/ignis` | OpenAPI documentation with Swagger UI or Scalar UI |
2050
- | **AuthenticateComponent** | `@venizia/ignis` | JWS/JWKS + Basic authentication strategies, token services, auth middleware |
2051
- | **AuthorizeComponent** | `@venizia/ignis` | Casbin-based RBAC authorization with enforcers |
2052
- | **RequestTrackerComponent** | `@venizia/ignis` | `x-request-id` header injection, request body parsing |
2053
- | **StaticAssetComponent** | `@venizia/ignis/static-asset` | File upload/download CRUD with MinIO, Disk, or Bun S3 storage |
2054
- | **MailComponent** | `@venizia/ignis/mail` | Email sending via Nodemailer/Mailgun with Direct/BullMQ/InternalQueue executors |
2055
- | **SocketIOComponent** | `@venizia/ignis/socket-io` | Socket.IO server with a mandatory Redis adapter for horizontal scaling |
2056
- | **WebSocketComponent** | `@venizia/ignis/websocket` | Native WebSocket support (Bun runtime) |
2057
- | **GrpcComponent** | `@venizia/ignis/grpc` | Auto-registered by `BaseApplication` (like `RestComponent`) -- mounts controllers whose `@controller` metadata has `transport: 'grpc'` via ConnectRPC; never registered manually with `this.component()` |
2058
-
2059
- ### Health Check Component
2060
-
2061
- ```typescript
2062
- import { HealthCheckComponent, HealthCheckBindingKeys, IHealthCheckOptions } from '@venizia/ignis';
2063
-
2064
- this.bind<IHealthCheckOptions>({ key: HealthCheckBindingKeys.HEALTH_CHECK_OPTIONS }).toValue({
2065
- restOptions: { path: '/health-check' }, // optional, default: /health
2066
- });
2067
- this.component(HealthCheckComponent);
2068
- ```
2069
-
2070
- `GET /health` (basic status) and `POST /health/ping` (ping/pong echo) -- no separate liveness/readiness probe pair. Details: [Health Check](https://github.com/VENIZIA-AI/ignis/blob/main/docs/wiki/content/extensions/components/health-check.md).
2071
-
2072
- ### Swagger / OpenAPI Component
2073
-
2074
- ```typescript
2075
- import { SwaggerComponent, SwaggerBindingKeys, ISwaggerOptions } from '@venizia/ignis';
2076
-
2077
- this.bind<ISwaggerOptions>({ key: SwaggerBindingKeys.SWAGGER_OPTIONS }).toValue({
2078
- restOptions: { base: { path: '/doc' }, doc: { path: '/openapi.json' }, ui: { path: '/explorer', type: 'scalar' } },
2079
- });
2080
- this.component(SwaggerComponent);
2081
- ```
2082
-
2083
- Auto-populates `info` from `getAppInfo()`, registers JWT/Basic security schemes, and serves Scalar or Swagger UI depending on `ui.type`. Details: [Swagger](https://github.com/VENIZIA-AI/ignis/blob/main/docs/wiki/content/extensions/components/swagger.md).
2084
-
2085
- ### Authentication Component
2086
-
2087
- Basic auth plus Bearer tokens under two JOSE standards -- **JWS** (HMAC-signed, single shared secret) and **JWKS** (asymmetric, issuer/verifier split, `/certs` publishing) -- with optional AES-encrypted payloads:
2088
-
2089
- ```typescript
2090
- import { AuthenticateComponent, AuthenticateBindingKeys, JOSEStandards, TJWTTokenServiceOptions } from '@venizia/ignis';
2091
-
2092
- this.bind<TJWTTokenServiceOptions>({ key: AuthenticateBindingKeys.JWT_OPTIONS }).toValue({
2093
- standard: JOSEStandards.JWS,
2094
- options: { jwtSecret: process.env.JWT_SECRET!, headerAlgorithm: 'HS256', getTokenExpiresFn: () => 86400 },
2095
- });
2096
- this.component(AuthenticateComponent);
2097
- ```
2098
-
2099
- Strategies (`jwt`/`basic`) register via `AuthenticationStrategyRegistry`; the current user is available via `context.get('auth.current.user')` after the middleware runs. Authentication mode is `any` (first success wins) or `all` (every strategy must pass). Full setup (JWKS issuer/verifier mode, token service API, route vs controller-level auth): [Authentication](https://github.com/VENIZIA-AI/ignis/blob/main/docs/wiki/content/extensions/components/authentication/index.md).
2100
-
2101
- ### Authorization Component
2102
-
2103
- Casbin-based RBAC. `IAuthorizeOptions` carries the component-wide decision policy; the enforcer is registered separately through `AuthorizationEnforcerRegistry`:
2104
-
2105
- ```typescript
2106
- import { AuthorizeComponent, AuthorizeBindingKeys, AuthorizationDecisions } from '@venizia/ignis';
2107
-
2108
- this.bind({ key: AuthorizeBindingKeys.OPTIONS }).toValue({
2109
- defaultDecision: AuthorizationDecisions.DENY,
2110
- alwaysAllowRoles: ['superadmin'],
2111
- });
2112
- this.component(AuthorizeComponent);
2113
- ```
2114
-
2115
- Apply with `authorize: { action, resource }` (or an array, all must pass) in a route's `configs`. Full enforcer registration and Casbin model setup: [Authorization](https://github.com/VENIZIA-AI/ignis/blob/main/docs/wiki/content/extensions/components/authorization/index.md).
2116
-
2117
- ### Static Asset Component
2118
-
2119
- File upload/download with MinIO, Disk, or Bun S3 storage. Sub-path only, like `mail`/`socket-io`/`websocket`:
2120
-
2121
- ```typescript
2122
- import { StaticAssetComponent, StaticAssetComponentBindingKeys, StaticAssetStorageTypes } from '@venizia/ignis/static-asset';
2123
- import { DiskHelper } from '@venizia/ignis-helpers';
2124
-
2125
- this.bind({ key: StaticAssetComponentBindingKeys.STATIC_ASSET_COMPONENT_OPTIONS }).toValue({
2126
- staticAsset: { controller: { name: 'AssetController', basePath: '/assets' }, storage: StaticAssetStorageTypes.DISK, helper: new DiskHelper({ basePath: './uploads' }) },
2127
- });
2128
- this.component(StaticAssetComponent);
2129
- ```
2130
-
2131
- MinIO and Bun S3 backends follow the same shape, swapping `storage` and `helper`. Details: [Static Asset](https://github.com/VENIZIA-AI/ignis/blob/main/docs/wiki/content/extensions/components/static-asset/index.md).
2132
-
2133
- ### Mail Component
2134
-
2135
- ```typescript
2136
- import { MailComponent } from '@venizia/ignis/mail';
2137
- ```
2138
-
2139
- Transporters: Nodemailer (SMTP), Mailgun (API). Executors: Direct (synchronous), BullMQ (Redis-backed queue), InternalQueue (in-memory). Details: [Mail](https://github.com/VENIZIA-AI/ignis/blob/main/docs/wiki/content/extensions/components/mail/index.md).
2140
-
2141
- ### Socket.IO Component
2142
-
2143
- ```typescript
2144
- import { SocketIOComponent, SocketIOBindingKeys } from '@venizia/ignis/socket-io';
2145
-
2146
- this.bind({ key: SocketIOBindingKeys.SERVER_OPTIONS }).toValue({ cors: { origin: '*' } });
2147
- // A Redis connection and an authenticate handler are both mandatory (unlike most other components).
2148
- this.bind({ key: SocketIOBindingKeys.REDIS_CONNECTION }).toValue(redisHelper);
2149
- this.bind({ key: SocketIOBindingKeys.AUTHENTICATE_HANDLER }).toValue(authenticateSocket);
2150
- this.component(SocketIOComponent);
2151
- ```
2152
-
2153
- Bun and Node.js runtime handlers (auto-detected), Redis adapter for horizontal scaling. Details: [Socket.IO](https://github.com/VENIZIA-AI/ignis/blob/main/docs/wiki/content/extensions/components/socket-io/index.md).
2154
-
2155
- ---
2156
-
2157
- ## Request Context
2158
-
2159
- Access the Hono request context from anywhere using `useRequestContext()`:
2160
-
2161
- ```typescript
2162
- import { useRequestContext, IAuthUser } from '@venizia/ignis';
2163
-
2164
- function getCurrentRequestId(): string | undefined {
2165
- const context = useRequestContext();
2166
- return context?.get('requestId');
2167
- }
2168
-
2169
- function getCurrentUser(): IAuthUser | undefined {
2170
- const context = useRequestContext();
2171
- return context?.get('auth.current.user');
2172
- }
2173
- ```
2174
-
2175
- This uses Hono's `contextStorage()` which stores the context in `AsyncLocalStorage`. It is available anywhere within the request lifecycle -- services, repositories, helpers, enrichers, etc.
2176
-
2177
- **Note:** Requires `asyncContext.enable: true` in application config (the default).
2178
-
2179
- ---
2180
-
2181
- ## Middleware System
2182
-
2183
- ### Registering Custom Middleware
2184
-
2185
- Add middleware in `setupMiddlewares()` -- these run on every request:
2186
-
2187
- ```typescript
2188
- setupMiddlewares(): ValueOrPromise<void> {
2189
- const server = this.getServer();
2190
-
2191
- // CORS
2192
- server.use('*', cors({
2193
- origin: ['https://myapp.com', 'https://admin.myapp.com'],
2194
- allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
2195
- allowHeaders: ['Content-Type', 'Authorization'],
2196
- credentials: true,
2197
- }));
2198
-
2199
- // Body size limit
2200
- server.use('*', bodyLimit({ maxSize: 50 * 1024 * 1024 })); // 50MB
2201
-
2202
- // Custom logging middleware
2203
- server.use('*', async (c, next) => {
2204
- const start = Date.now();
2205
- await next();
2206
- const duration = Date.now() - start;
2207
- console.log(`${c.req.method} ${c.req.path} ${c.res.status} ${duration}ms`);
2208
- });
2209
-
2210
- // Rate limiting on specific paths
2211
- server.use('/api/auth/*', rateLimiter({
2212
- windowMs: 15 * 60 * 1000, // 15 minutes
2213
- limit: 100,
2214
- }));
2215
- }
2216
- ```
2217
-
2218
- ### Default Middleware Stack (registered automatically)
2219
-
2220
- 1. `appErrorHandler` -- Global error handler
2221
- 2. `contextStorage` -- Async context for `useRequestContext()`
2222
- 3. `notFoundHandler` -- Structured 404 responses
2223
- 4. `RequestTrackerComponent` -- Request ID injection + body parsing
2224
- 5. `emojiFavicon` -- Favicon handler
2225
-
2226
- ---
2227
-
2228
- ## Error Handling
2229
-
2230
- ### Error Propagation
2231
-
2232
- Errors propagate through the layer stack and are caught by the global `appErrorHandler` (Hono's `onError`), which routes each error to one of four shapes:
2233
-
2234
- ```
2235
- ZodError -> 422, via formatZodError()
2236
- DB client error (SQLSTATE class 22/23/44) -> 400, via isDatabaseClientError()
2237
- Transient DB conflict (40001/40P01) -> 409, via isRetryableDatabaseError()
2238
- Everything else (incl. getError()) -> its own statusCode, default 500
2239
- ```
2240
-
2241
- ```
2242
- Controller throws -> appErrorHandler catches -> JSON error response
2243
- Service throws -> Controller doesn't catch -> appErrorHandler catches
2244
- Repository throws -> Service doesn't catch -> Controller doesn't catch -> appErrorHandler
2245
- ```
2246
-
2247
- ### Error Response Format
2248
-
2249
- ```json
2250
- {
2251
- "message": "Error description",
2252
- "messageCode": "some.message.code",
2253
- "statusCode": 500,
2254
- "requestId": "abc-123-def",
2255
- "extra": { "any": "extra context attached via getError({ extra })" },
2256
- "details": {
2257
- "url": "http://localhost:3000/api/users",
2258
- "path": "/api/users",
2259
- "stack": "Error: ...\n at ...",
2260
- "cause": { "code": "23505", "detail": "Key (email)=(john@example.com) already exists." }
2261
- }
2262
- }
2263
- ```
2264
-
2265
- In production (`NODE_ENV=production`), `stack` and `cause` are stripped from responses, and an unexpected (non-`getError`) 500 has its `message` replaced with a generic `"Internal Server Error"` so raw driver/connection errors never leak.
2266
-
2267
- ### PostgreSQL Constraint Errors
2268
-
2269
- The error handler classifies by SQLSTATE **class** (the code's first two characters), not an exhaustive list of individual codes:
2270
-
2271
- | SQLSTATE class | Meaning | Resolved status |
2272
- | --- | --- | --- |
2273
- | `22` | Data exception (e.g. `22001` string too long, `22003` numeric out of range, `22P02` invalid text representation) | 400 |
2274
- | `23` | Integrity constraint violation (e.g. `23505` unique, `23503` foreign key, `23502` not null, `23514` check, `23P01` exclusion) | 400 |
2275
- | `44` | WITH CHECK OPTION violation | 400 |
2276
- | `40001` / `40P01` (exact codes, not a class) | Serialization failure / deadlock detected -- transient, safe to retry | 409, `messageCode: 'database.conflict'` |
2277
-
2278
- In production, only the generic per-code message is returned; outside production, `detail`/`table`/`constraint` from the driver are appended for debugging.
2279
-
2280
- ### Throwing Application Errors
2281
-
2282
- Use `getError()` from helpers to throw errors with specific status codes:
2283
-
2284
- ```typescript
2285
- import { getError, HTTP } from '@venizia/ignis-helpers';
2286
-
2287
- throw getError({
2288
- statusCode: HTTP.ResultCodes.RS_4.NotFound,
2289
- message: 'User not found',
2290
- });
2291
-
2292
- throw getError({
2293
- statusCode: HTTP.ResultCodes.RS_4.Forbidden,
2294
- message: 'Insufficient permissions',
2295
- });
2296
- ```
2297
-
2298
- `throwNotSupported()` (`src/utilities/error.utility.ts`) is the standardized way connectors reject a capability they deliberately don't implement (e.g. Typesense/Memory transactions and locks): it logs a warning, then throws via `getError()` with `messageCode: 'core.not_supported'` and HTTP 501.
2299
-
2300
- ---
2301
-
2302
- ## Decorators Reference
2303
-
2304
- | Decorator | Target | Parameters | Description |
2305
- | --- | --- | --- | --- |
2306
- | `@model({ type, settings?, tableName?, skipMigrate? })` | Class | `type`: `'entity' \| 'view'` (required); `settings.hiddenProperties`: string[]; `settings.defaultFilter`: TFilter | Register entity model with hidden properties and default filters |
2307
- | `@datasource({ driver, autoDiscovery? })` | Class | `driver`: e.g. `'node-postgres'`, `'typesense'` (required); `autoDiscovery`: boolean (default true) | Register datasource with driver configuration |
2308
- | `@repository({ model, dataSource })` | Class | `model`: entity class; `dataSource`: datasource class | Bind repository to model and datasource; auto-injects datasource at param[0] |
2309
- | `@controller({ path })` | Class | `path`: base path string | Register controller with base path. Default authentication/authorization is set at the `ControllerFactory` level, not on this decorator |
2310
- | `@get({ configs })` | Method | Full route config (path, request, responses, authenticate, authorize, middleware) | Define GET route |
2311
- | `@post({ configs })` | Method | Same as `@get` | Define POST route |
2312
- | `@put({ configs })` | Method | Same as `@get` | Define PUT route |
2313
- | `@patch({ configs })` | Method | Same as `@get` | Define PATCH route |
2314
- | `@del({ configs })` | Method | Same as `@get` | Define DELETE route |
2315
- | `@api({ configs })` | Method | Same as `@get` + `method` field | Define route with explicit HTTP method |
2316
- | `@inject({ key, isOptional? })` | Constructor param / Property | `key`: binding key string or symbol; `isOptional`: boolean (default false) | Inject dependency from IoC container |
2317
- | `@injectable({ scope?, tags? })` | Class | `scope`: `'singleton'` or `'transient'`; `tags`: string[] | Mark class as injectable with scope and tags |
2318
-
2319
- ---
2320
-
2321
- ## Response Helpers
2322
-
2323
- Utility functions for building OpenAPI-compliant response and request schemas:
2324
-
2325
- ```typescript
2326
- import { jsonContent, jsonResponse, htmlResponse, idParamsSchema } from '@venizia/ignis';
2327
- import { z } from '@hono/zod-openapi';
2328
-
2329
- // JSON request body
2330
- jsonContent({
2331
- schema: z.object({ name: z.string(), email: z.string() }),
2332
- description: 'User creation payload',
2333
- });
2334
- // => { description, content: { 'application/json': { schema } } }
2335
-
2336
- // JSON response with automatic error fallback
2337
- jsonResponse({
2338
- schema: z.object({ id: z.number(), name: z.string() }),
2339
- description: 'User object',
2340
- headers: {
2341
- 'x-request-id': { description: 'Request ID', schema: { type: 'string' } },
2342
- },
2343
- });
2344
- // => { 200: { ... }, '4xx | 5xx': { ... ErrorSchema ... } }
2345
-
2346
- // HTML response
2347
- htmlResponse({ description: 'Rendered page' });
2348
- // => { 200: { content: { 'text/html': { schema } } }, '4xx | 5xx': { ... } }
2349
-
2350
- // Path parameter schema
2351
- idParamsSchema({ idType: 'number' });
2352
- // => z.object({ id: z.number() })
2353
-
2354
- idParamsSchema({ idType: 'string' });
2355
- // => z.object({ id: z.string() })
2356
- ```
2357
-
2358
- ---
2359
-
2360
- ## Real-World Patterns
2361
-
2362
- ### Complete User CRUD with Auth, Validation, Soft Delete, Pagination
2363
-
2364
- A model with hidden fields + soft delete, a repository, a service enforcing a uniqueness rule, and a controller wiring auth/authorization onto routes -- the full layered pattern in one file group:
2365
-
2366
- ```typescript
2367
- // models/user.model.ts
2368
- @model({ type: 'entity', settings: { hiddenProperties: ['password'], defaultFilter: { where: { isDeleted: false } } } })
2369
- export class User extends BasePostgresEntity<typeof User.schema> {
2370
- static override schema = pgTable('User', {
2371
- ...generateIdColumnDefs({ id: { dataType: 'string' } }),
2372
- ...generateTzColumnDefs({ deleted: { enable: true, columnName: 'deleted_at', withTimezone: true } }),
2373
- username: text('username').notNull().unique(),
2374
- email: text('email').notNull().unique(),
2375
- password: text('password'),
2376
- isDeleted: boolean('is_deleted').default(false).notNull(),
2377
- });
2378
- static override relations = () => [];
2379
- static override TABLE_NAME = 'User';
2380
- }
2381
-
2382
- // repositories/user.repository.ts
2383
- @repository({ model: User, dataSource: PostgresDataSource })
2384
- export class UserRepository extends DefaultCRUDRepository<typeof User.schema> {}
2385
-
2386
- // services/user.service.ts -- enforces a uniqueness rule before delegating to the repository
2387
- export class UserService extends BaseService {
2388
- constructor(@inject({ key: 'repositories.UserRepository' }) private userRepository: UserRepository) {
2389
- super({ scope: UserService.name });
2390
- }
2391
-
2392
- async createUser(data: { username: string; email: string; password: string }) {
2393
- const exists = await this.userRepository.existsWith({ where: { email: data.email } });
2394
- if (exists) {
2395
- throw getError({ statusCode: HTTP.ResultCodes.RS_4.Conflict, message: 'Email already in use' });
2396
- }
2397
- return this.userRepository.create({ data: { ...data, password: await Bun.password.hash(data.password) } });
2398
- }
2399
- }
2400
-
2401
- // controllers/user.controller.ts -- @get is public, @post requires jwt + authorization
2402
- @controller({ path: '/users' })
2403
- export class UserController extends BaseRestController {
2404
- constructor(@inject({ key: 'services.UserService' }) private userService: UserService) {
2405
- super({ scope: UserController.name });
2406
- }
2407
- override binding() {}
2408
- }
2409
- ```
2410
-
2411
- The full walkthrough (pagination, controller-level find/create/soft-delete routes, OpenAPI schemas) is the [Building a CRUD API tutorial](https://github.com/VENIZIA-AI/ignis/blob/main/docs/wiki/content/guides/tutorials/building-a-crud-api.md).
2412
-
2413
- ---
2414
-
2415
- ## Testing
2416
-
2417
- IGNIS uses the Bun test runner throughout. A repository test needs only a configured DataSource:
2418
-
2419
- ```typescript
2420
- import { describe, test, expect, beforeAll } from 'bun:test';
2421
-
2422
- describe('UserRepository', () => {
2423
- let repository: UserRepository;
2424
-
2425
- beforeAll(async () => {
2426
- const dataSource = new PostgresDataSource();
2427
- await dataSource.configure();
2428
- repository = new UserRepository(dataSource, { entityClass: User });
2429
- });
2430
-
2431
- test('create and find user', async () => {
2432
- const { data: user } = await repository.create({ data: { username: 'test', email: 'test@example.com' } });
2433
- expect(user.id).toBeDefined();
2434
-
2435
- const found = await repository.findById({ id: user.id });
2436
- expect(found!.email).toBe('test@example.com');
2437
- });
2438
- });
2439
- ```
2440
-
2441
- Controller tests drive `app.getServer().fetch()` against a real running `Application` -- routes are only mounted inside `start()`. Full patterns (hidden-field/default-filter assertions, request validation, auth): [Testing tutorial](https://github.com/VENIZIA-AI/ignis/blob/main/docs/wiki/content/guides/tutorials/testing.md).
2442
-
2443
- ---
2444
-
2445
- ## Performance Tips
2446
-
2447
- 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.
2448
-
2449
- 2. **Lazy Entity Resolution** -- Entity instances are resolved from metadata only on first access. This avoids unnecessary construction during application startup.
2450
-
2451
- 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.
2452
-
2453
- 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.
2454
-
2455
- 5. **Visible Property Caching** -- `PostgresBaseRepository.getVisibleProperties()` computes the visible property set once and caches it. Subsequent queries reuse the cached column selection.
2456
-
2457
- 6. **Parallel Count + Data** -- When `shouldQueryRange: true`, the data fetch and count query run in parallel via `Promise.all`, not sequentially.
2458
-
2459
- 7. **Schema Factory Sharing** -- `BasePostgresEntity` uses a lazy singleton for the Drizzle-Zod schema factory, shared across all entity instances. Schema generation does not create redundant factory objects.
2460
-
2461
- 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.
2462
-
2463
- 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.
2464
-
2465
- 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.
2466
-
2467
- ---
2468
-
2469
- ## Documentation
2470
-
2471
- - [IGNIS Repository](https://github.com/VENIZIA-AI/ignis)
2472
- - [Getting Started](https://github.com/VENIZIA-AI/ignis/blob/main/docs/wiki/content/guides/index.md)
2473
- - [Core Concepts](https://github.com/VENIZIA-AI/ignis/blob/main/docs/wiki/content/guides/core-concepts/application/index.md)
2474
- - [Examples](https://github.com/VENIZIA-AI/ignis/tree/main/examples/vert)
2475
-
2476
- ---
2477
-
2478
- ## License
2479
-
2480
- MIT
153
+ Register with `this.component(X)` in `preConfigure()`.
154
+
155
+ | Component | Import from | What it adds |
156
+ | :--- | :--- | :--- |
157
+ | `ApiReferenceComponent` | `@venizia/ignis` | OpenAPI spec plus a Scalar or Swagger UI explorer |
158
+ | `AuthenticateComponent` | `@venizia/ignis` | JWT and Basic authentication strategies |
159
+ | `AuthorizeComponent` | `@venizia/ignis` | Casbin-backed authorization enforcement |
160
+ | `HealthCheckComponent` | `@venizia/ignis` | Liveness and readiness endpoints |
161
+ | `RequestTrackerComponent` | `@venizia/ignis` | Request IDs and body parsing - registered automatically |
162
+ | `RestComponent` | `@venizia/ignis` | Binds discovered REST controllers - internal, registered automatically |
163
+ | `GrpcComponent` | `@venizia/ignis/grpc` | ConnectRPC transport for `@rpc` controllers |
164
+ | `MailComponent` | `@venizia/ignis/mail` | Nodemailer and Mailgun senders |
165
+ | `SocketIOComponent` | `@venizia/ignis/socket-io` | Socket.IO server with an optional Redis adapter |
166
+ | `WebSocketComponent` | `@venizia/ignis/websocket` | Native WebSocket transport |
167
+ | `StaticAssetComponent` | `@venizia/ignis/static-asset` | Static file serving and asset controllers |
168
+
169
+ `SwaggerComponent` is a deprecated alias for `ApiReferenceComponent`.
170
+
171
+ Full configuration for each: [Components](https://ignis.venizia.ai/extensions/components/).
172
+
173
+ ## Sub-path exports
174
+
175
+ The root barrel deliberately excludes optional transports and connectors, so a bundler never pulls in
176
+ a peer you did not install.
177
+
178
+ | Sub-path | Contents |
179
+ | :--- | :--- |
180
+ | `@venizia/ignis/postgres` | Relational models, datasources, repositories, dialect |
181
+ | `@venizia/ignis/postgres/node-postgres` | `pg` driver (the supported default) |
182
+ | `@venizia/ignis/postgres/postgres-js` | `postgres` driver |
183
+ | `@venizia/ignis/postgres/supabase` | Supabase driver and RLS auth context |
184
+ | `@venizia/ignis/search` (+ `/controllers`) | Engine-agnostic search connector |
185
+ | `@venizia/ignis/typesense` (+ `/controllers`) | Typesense engine |
186
+ | `@venizia/ignis/meilisearch` | Meilisearch engine |
187
+ | `@venizia/ignis/grpc`, `/socket-io`, `/websocket`, `/mail`, `/static-asset` | Optional components |
188
+
189
+ Note that `@venizia/ignis/postgres` is also re-exported from the root barrel; the other sub-paths are
190
+ not.
191
+
192
+ ## Things that will burn you
193
+
194
+ - **Errors**: always `getError()` / `ApplicationError` from `@venizia/ignis-helpers`, never raw
195
+ `new Error`. The error shape is one object - `{ text, code, args }`. Across package boundaries use
196
+ `isApplicationError()`, never `instanceof`.
197
+ - **Logging**: type your loggers as `ILogger`. Get one from `BaseHelper.logger`,
198
+ `LoggerFactory.getLogger(['A','B'])` or `ApplicationLogger.get('Scope')`, and `.for('method')` for a
199
+ method-scoped child. Five levels only: `debug`, `info`, `warn`, `error`, `emerg`.
200
+ - **Runtime**: Bun >= 1.3 first-class. Node.js works through the optional `@hono/node-server` peer.
201
+ - **Version**: 0.x - minor versions can break. Pin exact versions and read the
202
+ [changelog](https://ignis.venizia.ai/changelogs/) before upgrading.
203
+
204
+ ## Links
205
+
206
+ [Quickstart](https://ignis.venizia.ai/guides/get-started/5-minute-quickstart) &#8226;
207
+ [API reference](https://ignis.venizia.ai/references/) &#8226;
208
+ [Bootstrapping](https://ignis.venizia.ai/references/base/bootstrapping) &#8226;
209
+ [Dependency injection](https://ignis.venizia.ai/references/base/dependency-injection) &#8226;
210
+ [Extensions](https://ignis.venizia.ai/extensions/) &#8226;
211
+ [Helpers](https://ignis.venizia.ai/extensions/helpers/) &#8226;
212
+ [Best practices](https://ignis.venizia.ai/best-practices/) &#8226;
213
+ [Changelog](https://ignis.venizia.ai/changelogs/)
214
+
215
+ MIT licensed - see [LICENSE.md](LICENSE.md).
216
+ Questions: [GitHub Issues](https://github.com/VENIZIA-AI/ignis/issues) &#8226; developer@venizia.ai