nestjs-ddd-cli 3.2.1 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. package/README.md +118 -9
  2. package/ddd.schema.json +11 -1
  3. package/dist/commands/doctor-enhanced.d.ts +1 -0
  4. package/dist/commands/doctor-enhanced.js +94 -79
  5. package/dist/commands/doctor-enhanced.js.map +1 -1
  6. package/dist/commands/doctor.d.ts +9 -0
  7. package/dist/commands/doctor.js +68 -21
  8. package/dist/commands/doctor.js.map +1 -1
  9. package/dist/commands/generate-all.js +238 -176
  10. package/dist/commands/generate-all.js.map +1 -1
  11. package/dist/commands/generate-controller.d.ts +1 -0
  12. package/dist/commands/generate-controller.js +71 -0
  13. package/dist/commands/generate-controller.js.map +1 -0
  14. package/dist/commands/generate-domain-service.d.ts +1 -1
  15. package/dist/commands/generate-domain-service.js +6 -4
  16. package/dist/commands/generate-domain-service.js.map +1 -1
  17. package/dist/commands/generate-dto.d.ts +4 -0
  18. package/dist/commands/generate-dto.js +162 -0
  19. package/dist/commands/generate-dto.js.map +1 -0
  20. package/dist/commands/generate-entity.js +44 -35
  21. package/dist/commands/generate-entity.js.map +1 -1
  22. package/dist/commands/generate-event.js +10 -4
  23. package/dist/commands/generate-event.js.map +1 -1
  24. package/dist/commands/generate-migration-deployment.d.ts +15 -0
  25. package/dist/commands/generate-migration-deployment.js +326 -0
  26. package/dist/commands/generate-migration-deployment.js.map +1 -0
  27. package/dist/commands/generate-migration-deployment.spec.d.ts +1 -0
  28. package/dist/commands/generate-migration-deployment.spec.js +337 -0
  29. package/dist/commands/generate-migration-deployment.spec.js.map +1 -0
  30. package/dist/commands/generate-module.js +41 -31
  31. package/dist/commands/generate-module.js.map +1 -1
  32. package/dist/commands/generate-query.js +23 -4
  33. package/dist/commands/generate-query.js.map +1 -1
  34. package/dist/commands/generate-repository.d.ts +1 -1
  35. package/dist/commands/generate-repository.js +17 -34
  36. package/dist/commands/generate-repository.js.map +1 -1
  37. package/dist/commands/generate-service.js +19 -4
  38. package/dist/commands/generate-service.js.map +1 -1
  39. package/dist/commands/generate-usecase.js +12 -31
  40. package/dist/commands/generate-usecase.js.map +1 -1
  41. package/dist/commands/generate-value-object.js +3 -14
  42. package/dist/commands/generate-value-object.js.map +1 -1
  43. package/dist/commands/init-project.d.ts +3 -0
  44. package/dist/commands/init-project.js +19 -9
  45. package/dist/commands/init-project.js.map +1 -1
  46. package/dist/commands/interactive-scaffold.js +10 -0
  47. package/dist/commands/interactive-scaffold.js.map +1 -1
  48. package/dist/commands/migration.d.ts +3 -0
  49. package/dist/commands/migration.js +83 -48
  50. package/dist/commands/migration.js.map +1 -1
  51. package/dist/commands/recipe.d.ts +2 -0
  52. package/dist/commands/recipe.js +282 -91
  53. package/dist/commands/recipe.js.map +1 -1
  54. package/dist/commands/recipes/banklink-connector-contract.recipe.d.ts +1 -0
  55. package/dist/commands/recipes/banklink-connector-contract.recipe.js +161 -0
  56. package/dist/commands/recipes/banklink-connector-contract.recipe.js.map +1 -0
  57. package/dist/commands/recipes/bounded-json-http.recipe.d.ts +2 -0
  58. package/dist/commands/recipes/bounded-json-http.recipe.js +53 -0
  59. package/dist/commands/recipes/bounded-json-http.recipe.js.map +1 -0
  60. package/dist/commands/recipes/business-reference-identifiers.recipe.d.ts +1 -0
  61. package/dist/commands/recipes/business-reference-identifiers.recipe.js +223 -0
  62. package/dist/commands/recipes/business-reference-identifiers.recipe.js.map +1 -0
  63. package/dist/commands/recipes/event-backbone.recipe.d.ts +1 -0
  64. package/dist/commands/recipes/event-backbone.recipe.js +1118 -0
  65. package/dist/commands/recipes/event-backbone.recipe.js.map +1 -0
  66. package/dist/commands/recipes/external-projection-worker.recipe.d.ts +6 -0
  67. package/dist/commands/recipes/external-projection-worker.recipe.js +211 -0
  68. package/dist/commands/recipes/external-projection-worker.recipe.js.map +1 -0
  69. package/dist/commands/recipes/index.d.ts +12 -0
  70. package/dist/commands/recipes/index.js +12 -0
  71. package/dist/commands/recipes/index.js.map +1 -1
  72. package/dist/commands/recipes/jest-cjs-compat.recipe.d.ts +3 -0
  73. package/dist/commands/recipes/jest-cjs-compat.recipe.js +130 -0
  74. package/dist/commands/recipes/jest-cjs-compat.recipe.js.map +1 -0
  75. package/dist/commands/recipes/joi-env.recipe.d.ts +1 -0
  76. package/dist/commands/recipes/joi-env.recipe.js +301 -0
  77. package/dist/commands/recipes/joi-env.recipe.js.map +1 -0
  78. package/dist/commands/recipes/kafka-consumer.recipe.d.ts +1 -0
  79. package/dist/commands/recipes/kafka-consumer.recipe.js +65 -0
  80. package/dist/commands/recipes/kafka-consumer.recipe.js.map +1 -0
  81. package/dist/commands/recipes/oauth2-client-credentials.recipe.d.ts +1 -0
  82. package/dist/commands/recipes/oauth2-client-credentials.recipe.js +60 -0
  83. package/dist/commands/recipes/oauth2-client-credentials.recipe.js.map +1 -0
  84. package/dist/commands/recipes/oauth2-token-introspection.recipe.d.ts +1 -0
  85. package/dist/commands/recipes/oauth2-token-introspection.recipe.js +67 -0
  86. package/dist/commands/recipes/oauth2-token-introspection.recipe.js.map +1 -0
  87. package/dist/commands/recipes/oidc-bff-session.recipe.d.ts +1 -0
  88. package/dist/commands/recipes/oidc-bff-session.recipe.js +57 -0
  89. package/dist/commands/recipes/oidc-bff-session.recipe.js.map +1 -0
  90. package/dist/commands/recipes/oidc-dashboard.recipe.d.ts +1 -0
  91. package/dist/commands/recipes/oidc-dashboard.recipe.js +463 -0
  92. package/dist/commands/recipes/oidc-dashboard.recipe.js.map +1 -0
  93. package/dist/commands/recipes/oidc-resource-server.recipe.d.ts +1 -0
  94. package/dist/commands/recipes/oidc-resource-server.recipe.js +85 -0
  95. package/dist/commands/recipes/oidc-resource-server.recipe.js.map +1 -0
  96. package/dist/commands/recipes/platform-context.recipe.d.ts +1 -0
  97. package/dist/commands/recipes/platform-context.recipe.js +229 -0
  98. package/dist/commands/recipes/platform-context.recipe.js.map +1 -0
  99. package/dist/commands/recipes/platform-parc-authorization.recipe.d.ts +1 -0
  100. package/dist/commands/recipes/platform-parc-authorization.recipe.js +339 -0
  101. package/dist/commands/recipes/platform-parc-authorization.recipe.js.map +1 -0
  102. package/dist/commands/recipes/platform-parc-bound-authorization.recipe.d.ts +1 -0
  103. package/dist/commands/recipes/platform-parc-bound-authorization.recipe.js +56 -0
  104. package/dist/commands/recipes/platform-parc-bound-authorization.recipe.js.map +1 -0
  105. package/dist/commands/recipes/platform-service-access-request-context.recipe.d.ts +1 -0
  106. package/dist/commands/recipes/platform-service-access-request-context.recipe.js +103 -0
  107. package/dist/commands/recipes/platform-service-access-request-context.recipe.js.map +1 -0
  108. package/dist/commands/recipes/platform-service-runtime.recipe.d.ts +1 -0
  109. package/dist/commands/recipes/platform-service-runtime.recipe.js +415 -0
  110. package/dist/commands/recipes/platform-service-runtime.recipe.js.map +1 -0
  111. package/dist/index.js +82 -11
  112. package/dist/index.js.map +1 -1
  113. package/dist/templates/ai-context/CLAUDE.md.hbs +54 -3
  114. package/dist/templates/ai-context/conventions.md.hbs +20 -8
  115. package/dist/templates/command/create-command.hbs +8 -3
  116. package/dist/templates/command/delete-command.hbs +1 -1
  117. package/dist/templates/command/update-command.hbs +1 -1
  118. package/dist/templates/controller/application-controller.hbs +7 -0
  119. package/dist/templates/controller/controller.hbs +6 -0
  120. package/dist/templates/dto/create-dto.hbs +17 -5
  121. package/dist/templates/dto/filter-dto.hbs +1 -1
  122. package/dist/templates/dto/response-dto.hbs +1 -1
  123. package/dist/templates/entity/entity.hbs +4 -0
  124. package/dist/templates/mapper/mapper.hbs +6 -2
  125. package/dist/templates/orm-entity/orm-entity.hbs +9 -1
  126. package/dist/templates/prisma/prisma-mapper.hbs +6 -0
  127. package/dist/templates/prisma/prisma-repository.hbs +57 -5
  128. package/dist/templates/prisma/prisma-schema.hbs +2 -0
  129. package/dist/templates/query/get-all.query.hbs +10 -2
  130. package/dist/templates/recipes/bounded-json-http/README.md.hbs +36 -0
  131. package/dist/templates/recipes/external-projection-worker/README.md.hbs +157 -0
  132. package/dist/templates/recipes/external-projection-worker/canonical-json.ts.hbs +123 -0
  133. package/dist/templates/recipes/external-projection-worker/enqueuer.ts.hbs +167 -0
  134. package/dist/templates/recipes/external-projection-worker/index.ts.hbs +6 -0
  135. package/dist/templates/recipes/external-projection-worker/migration.ts.hbs +88 -0
  136. package/dist/templates/recipes/external-projection-worker/module.ts.hbs +36 -0
  137. package/dist/templates/recipes/external-projection-worker/store.ts.hbs +354 -0
  138. package/dist/templates/recipes/external-projection-worker/types.ts.hbs +134 -0
  139. package/dist/templates/recipes/external-projection-worker/worker.ts.hbs +296 -0
  140. package/dist/templates/recipes/kafka-consumer/README.md.hbs +50 -0
  141. package/dist/templates/recipes/kafka-consumer/kafka-consumer.ts.hbs +135 -0
  142. package/dist/templates/recipes/kafka-consumer/migration.ts.hbs +17 -0
  143. package/dist/templates/recipes/oauth2-client-credentials/README.md.hbs +40 -0
  144. package/dist/templates/recipes/oauth2-client-credentials/client.spec.ts.hbs +234 -0
  145. package/dist/templates/recipes/oauth2-client-credentials/client.ts.hbs +210 -0
  146. package/dist/templates/recipes/oauth2-client-credentials/index.ts.hbs +2 -0
  147. package/dist/templates/recipes/oauth2-client-credentials/module.ts.hbs +58 -0
  148. package/dist/templates/recipes/oauth2-token-introspection/README.md.hbs +158 -0
  149. package/dist/templates/recipes/oauth2-token-introspection/client.spec.ts.hbs +350 -0
  150. package/dist/templates/recipes/oauth2-token-introspection/client.ts.hbs +241 -0
  151. package/dist/templates/recipes/oauth2-token-introspection/http-client.spec.ts.hbs +411 -0
  152. package/dist/templates/recipes/oauth2-token-introspection/http-client.ts.hbs +343 -0
  153. package/dist/templates/recipes/oauth2-token-introspection/index.ts.hbs +3 -0
  154. package/dist/templates/recipes/oauth2-token-introspection/module.ts.hbs +58 -0
  155. package/dist/templates/recipes/oidc-bff-session/README.md.hbs +96 -0
  156. package/dist/templates/recipes/oidc-bff-session/client.test.ts.hbs +663 -0
  157. package/dist/templates/recipes/oidc-bff-session/client.ts.hbs +635 -0
  158. package/dist/templates/recipes/oidc-bff-session/index.ts.hbs +3 -0
  159. package/dist/templates/recipes/oidc-bff-session/store.integration.test.ts.hbs +480 -0
  160. package/dist/templates/recipes/oidc-bff-session/store.test.ts.hbs +87 -0
  161. package/dist/templates/recipes/oidc-bff-session/store.ts.hbs +549 -0
  162. package/dist/templates/recipes/oidc-bff-session/valkey.test.ts.hbs +158 -0
  163. package/dist/templates/recipes/oidc-bff-session/valkey.ts.hbs +148 -0
  164. package/dist/templates/recipes/oidc-resource-server/README.md.hbs +74 -0
  165. package/dist/templates/recipes/oidc-resource-server/index.ts.hbs +2 -0
  166. package/dist/templates/recipes/oidc-resource-server/module.ts.hbs +51 -0
  167. package/dist/templates/recipes/oidc-resource-server/verifier.spec.ts.hbs +186 -0
  168. package/dist/templates/recipes/oidc-resource-server/verifier.ts.hbs +440 -0
  169. package/dist/templates/recipes/platform-parc-bound-authorization/README.md.hbs +92 -0
  170. package/dist/templates/recipes/platform-parc-bound-authorization/client.ts.hbs +142 -0
  171. package/dist/templates/recipes/platform-service-access-request-context/docs.md.hbs +61 -0
  172. package/dist/templates/recipes/platform-service-access-request-context/env.example.hbs +7 -0
  173. package/dist/templates/recipes/platform-service-access-request-context/index.ts.hbs +7 -0
  174. package/dist/templates/recipes/platform-service-access-request-context/request-context-headers.ts.hbs +61 -0
  175. package/dist/templates/recipes/platform-service-access-request-context/request-context-introspection.parser.ts.hbs +132 -0
  176. package/dist/templates/recipes/platform-service-access-request-context/request-context-introspection.types.ts.hbs +40 -0
  177. package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.client.ts.hbs +45 -0
  178. package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.config.ts.hbs +142 -0
  179. package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.guard.ts.hbs +77 -0
  180. package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.module.ts.hbs +17 -0
  181. package/dist/templates/repository/repository.hbs +43 -5
  182. package/dist/templates/resolver/graphql-input.hbs +23 -6
  183. package/dist/templates/resolver/resolver.hbs +4 -0
  184. package/dist/templates/service/domain-service.hbs +2 -2
  185. package/dist/templates/test/controller.spec.hbs +5 -1
  186. package/dist/templates/test/prisma-repository.spec.hbs +192 -0
  187. package/dist/templates/test/repository.spec.hbs +41 -2
  188. package/dist/templates/test/usecase.spec.hbs +24 -5
  189. package/dist/templates/usecase/application-usecase.hbs +17 -0
  190. package/dist/templates/usecase/create-usecase.hbs +34 -3
  191. package/dist/templates/usecase/update-usecase.hbs +15 -1
  192. package/dist/utils/config.utils.d.ts +2 -0
  193. package/dist/utils/config.utils.js +14 -2
  194. package/dist/utils/config.utils.js.map +1 -1
  195. package/dist/utils/dependency.utils.d.ts +18 -2
  196. package/dist/utils/dependency.utils.js +144 -15
  197. package/dist/utils/dependency.utils.js.map +1 -1
  198. package/dist/utils/field.utils.d.ts +6 -0
  199. package/dist/utils/field.utils.js +78 -24
  200. package/dist/utils/field.utils.js.map +1 -1
  201. package/dist/utils/field.utils.spec.d.ts +1 -0
  202. package/dist/utils/field.utils.spec.js +38 -0
  203. package/dist/utils/field.utils.spec.js.map +1 -0
  204. package/dist/utils/file.utils.d.ts +39 -3
  205. package/dist/utils/file.utils.js +150 -13
  206. package/dist/utils/file.utils.js.map +1 -1
  207. package/dist/utils/naming.utils.js +5 -4
  208. package/dist/utils/naming.utils.js.map +1 -1
  209. package/dist/utils/static-recipe.utils.d.ts +4 -0
  210. package/dist/utils/static-recipe.utils.js +148 -0
  211. package/dist/utils/static-recipe.utils.js.map +1 -0
  212. package/dist/utils/template-engine.utils.js +83 -68
  213. package/dist/utils/template-engine.utils.js.map +1 -1
  214. package/package.json +20 -11
@@ -0,0 +1,1118 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.applyEventBackboneRecipe = applyEventBackboneRecipe;
40
+ const path = __importStar(require("path"));
41
+ const chalk_1 = __importDefault(require("chalk"));
42
+ const file_utils_1 = require("../../utils/file.utils");
43
+ const business_reference_identifiers_recipe_1 = require("./business-reference-identifiers.recipe");
44
+ const platform_context_recipe_1 = require("./platform-context.recipe");
45
+ async function applyEventBackboneRecipe(basePath) {
46
+ const sharedPath = path.join(basePath, 'src/shared');
47
+ const eventBackbonePath = path.join(sharedPath, 'event-backbone');
48
+ const entitiesPath = path.join(eventBackbonePath, 'entities');
49
+ const publishersPath = path.join(eventBackbonePath, 'publishers');
50
+ const migrationsPath = path.join(basePath, 'src/migrations');
51
+ await (0, business_reference_identifiers_recipe_1.applyBusinessReferenceIdentifiersRecipe)(basePath);
52
+ await (0, platform_context_recipe_1.applyPlatformContextRecipe)(basePath);
53
+ await (0, file_utils_1.ensureDir)(eventBackbonePath);
54
+ await (0, file_utils_1.ensureDir)(entitiesPath);
55
+ await (0, file_utils_1.ensureDir)(publishersPath);
56
+ await (0, file_utils_1.ensureDir)(migrationsPath);
57
+ const constantsContent = `export const EVENT_BACKBONE_PUBLISHER = Symbol("EVENT_BACKBONE_PUBLISHER");
58
+
59
+ export const EVENT_BACKBONE_TRANSPORT = {
60
+ relayHttp: "relay-http",
61
+ pulsar: "pulsar",
62
+ none: "none",
63
+ } as const;
64
+
65
+ export type EventBackboneTransport =
66
+ (typeof EVENT_BACKBONE_TRANSPORT)[keyof typeof EVENT_BACKBONE_TRANSPORT];
67
+
68
+ export const EVENT_BACKBONE_ENV = {
69
+ transport: "EVENT_BACKBONE_TRANSPORT",
70
+ relayEnabled: "EVENT_BACKBONE_RELAY_ENABLED",
71
+ relayBatchSize: "EVENT_BACKBONE_RELAY_BATCH_SIZE",
72
+ relayMaxAttempts: "EVENT_BACKBONE_RELAY_MAX_ATTEMPTS",
73
+ relayRetryBaseMs: "EVENT_BACKBONE_RELAY_RETRY_BASE_MS",
74
+ relayHttpUrl: "EVENT_BACKBONE_RELAY_HTTP_URL",
75
+ relayServiceCredential: "EVENT_BACKBONE_RELAY_SERVICE_CREDENTIAL",
76
+ relayAudience: "EVENT_BACKBONE_RELAY_AUDIENCE",
77
+ relayTimeoutMs: "EVENT_BACKBONE_RELAY_TIMEOUT_MS",
78
+ pulsarEnabled: "PULSAR_ENABLED",
79
+ pulsarServiceUrl: "PULSAR_SERVICE_URL",
80
+ pulsarTenant: "PULSAR_TENANT",
81
+ pulsarNamespace: "PULSAR_NAMESPACE",
82
+ pulsarTopicPrefix: "PULSAR_TOPIC_PREFIX",
83
+ } as const;
84
+ `;
85
+ await (0, file_utils_1.writeFile)(path.join(eventBackbonePath, 'event-backbone.constants.ts'), constantsContent);
86
+ const typesContent = `export type EventPayload = Record<string, unknown>;
87
+
88
+ export enum EventPiiClassification {
89
+ None = "NONE",
90
+ Internal = "INTERNAL",
91
+ Restricted = "RESTRICTED",
92
+ Sensitive = "SENSITIVE",
93
+ }
94
+
95
+ export interface EventBackboneActorMetadata {
96
+ subjectId?: string;
97
+ businessId?: string;
98
+ applicationId?: string;
99
+ serviceName?: string;
100
+ }
101
+
102
+ export interface EventBackboneMetadata {
103
+ schemaVersion?: number;
104
+ correlationId?: string;
105
+ causationId?: string;
106
+ idempotencyKey?: string;
107
+ actor?: EventBackboneActorMetadata;
108
+ businessId?: string;
109
+ applicationId?: string;
110
+ piiClassification?: EventPiiClassification;
111
+ tenantId?: string;
112
+ traceId?: string;
113
+ source?: string;
114
+ [key: string]: unknown;
115
+ }
116
+
117
+ export interface EventBackboneEnvelope<TPayload extends EventPayload = EventPayload> {
118
+ id: string;
119
+ schemaVersion: number;
120
+ reference?: string;
121
+ type: string;
122
+ source: string;
123
+ aggregateType: string;
124
+ aggregateId: string;
125
+ streamId: string;
126
+ version?: number;
127
+ subject?: string;
128
+ payload: TPayload;
129
+ metadata: EventBackboneMetadata;
130
+ occurredAt: string;
131
+ }
132
+
133
+ export interface AppendDomainEventInput<TPayload extends EventPayload = EventPayload> {
134
+ type: string;
135
+ aggregateType: string;
136
+ aggregateId: string;
137
+ payload: TPayload;
138
+ metadata?: EventBackboneMetadata;
139
+ occurredAt?: Date;
140
+ reference?: string;
141
+ }
142
+
143
+ export interface AppendDomainEventsInput {
144
+ streamId: string;
145
+ expectedVersion?: number;
146
+ events: AppendDomainEventInput[];
147
+ }
148
+
149
+ export interface StoredDomainEvent<TPayload extends EventPayload = EventPayload> {
150
+ globalSeq: string;
151
+ eventId: string;
152
+ reference?: string | null;
153
+ streamId: string;
154
+ version: number;
155
+ type: string;
156
+ aggregateType: string;
157
+ aggregateId: string;
158
+ payload: TPayload;
159
+ metadata: EventBackboneMetadata;
160
+ occurredAt: Date;
161
+ createdAt: Date;
162
+ }
163
+
164
+ export enum OutboxEventStatus {
165
+ Pending = "PENDING",
166
+ Publishing = "PUBLISHING",
167
+ Published = "PUBLISHED",
168
+ Failed = "FAILED",
169
+ }
170
+
171
+ export interface OutboxPublishInput<TPayload extends EventPayload = EventPayload> {
172
+ eventId: string;
173
+ type: string;
174
+ source: string;
175
+ aggregateType: string;
176
+ aggregateId: string;
177
+ streamId: string;
178
+ version: number;
179
+ payload: TPayload;
180
+ metadata?: EventBackboneMetadata;
181
+ occurredAt?: Date;
182
+ reference?: string;
183
+ subject?: string;
184
+ }
185
+
186
+ export interface EventBackbonePublisher {
187
+ publish(envelope: EventBackboneEnvelope): Promise<void>;
188
+ }
189
+ `;
190
+ await (0, file_utils_1.writeFile)(path.join(eventBackbonePath, 'event-backbone.types.ts'), typesContent);
191
+ const envContent = `import * as Joi from "joi";
192
+
193
+ export const eventBackboneEnvSchema = {
194
+ EVENT_BACKBONE_TRANSPORT: Joi.string()
195
+ .valid("relay-http", "pulsar", "none")
196
+ .default("none"),
197
+ EVENT_BACKBONE_RELAY_ENABLED: Joi.boolean().truthy("true").falsy("false").default(true),
198
+ EVENT_BACKBONE_RELAY_BATCH_SIZE: Joi.number().integer().min(1).max(500).default(50),
199
+ EVENT_BACKBONE_RELAY_MAX_ATTEMPTS: Joi.number().integer().min(1).max(100).default(10),
200
+ EVENT_BACKBONE_RELAY_RETRY_BASE_MS: Joi.number().integer().min(1000).default(30000),
201
+ EVENT_BACKBONE_RELAY_HTTP_URL: Joi.string()
202
+ .uri()
203
+ .when("EVENT_BACKBONE_TRANSPORT", {
204
+ is: "relay-http",
205
+ then: Joi.required(),
206
+ otherwise: Joi.string().allow("").optional(),
207
+ }),
208
+ EVENT_BACKBONE_RELAY_SERVICE_CREDENTIAL: Joi.string()
209
+ .min(1)
210
+ .when("EVENT_BACKBONE_TRANSPORT", {
211
+ is: "relay-http",
212
+ then: Joi.required(),
213
+ otherwise: Joi.string().allow("").optional(),
214
+ }),
215
+ EVENT_BACKBONE_RELAY_AUDIENCE: Joi.string()
216
+ .min(1)
217
+ .when("EVENT_BACKBONE_TRANSPORT", {
218
+ is: "relay-http",
219
+ then: Joi.required(),
220
+ otherwise: Joi.string().allow("").optional(),
221
+ }),
222
+ EVENT_BACKBONE_RELAY_TIMEOUT_MS: Joi.number().integer().min(1000).max(60000).default(10000),
223
+ PULSAR_ENABLED: Joi.boolean().truthy("true").falsy("false").default(false),
224
+ PULSAR_SERVICE_URL: Joi.string()
225
+ .pattern(/^pulsars?:\\/\\/.+/)
226
+ .when("EVENT_BACKBONE_TRANSPORT", {
227
+ is: "pulsar",
228
+ then: Joi.required(),
229
+ otherwise: Joi.when("PULSAR_ENABLED", {
230
+ is: true,
231
+ then: Joi.required(),
232
+ otherwise: Joi.string().allow("").optional(),
233
+ }),
234
+ }),
235
+ PULSAR_TENANT: Joi.string().default("public"),
236
+ PULSAR_NAMESPACE: Joi.string().default("default"),
237
+ PULSAR_TOPIC_PREFIX: Joi.string().allow("").default(""),
238
+ };
239
+ `;
240
+ await (0, file_utils_1.writeFile)(path.join(eventBackbonePath, 'event-backbone.env.ts'), envContent);
241
+ const errorsContent = `export class EventStreamConcurrencyError extends Error {
242
+ constructor(streamId: string, expectedVersion: number | undefined, actualVersion: number) {
243
+ super(
244
+ "Event stream concurrency conflict for " +
245
+ streamId +
246
+ ": expected " +
247
+ String(expectedVersion) +
248
+ ", actual " +
249
+ String(actualVersion),
250
+ );
251
+ this.name = "EventStreamConcurrencyError";
252
+ }
253
+ }
254
+
255
+ export class OutboxTransactionRequiredError extends Error {
256
+ constructor() {
257
+ super("Outbox writes must use the same active database transaction as the business write");
258
+ this.name = "OutboxTransactionRequiredError";
259
+ }
260
+ }
261
+
262
+ export class RelayHttpRetryableError extends Error {
263
+ readonly statusCode?: number;
264
+
265
+ constructor(message: string, statusCode?: number) {
266
+ super(message);
267
+ this.name = "RelayHttpRetryableError";
268
+ this.statusCode = statusCode;
269
+ }
270
+ }
271
+
272
+ export class RelayHttpNonRetryableError extends Error {
273
+ readonly statusCode?: number;
274
+
275
+ constructor(message: string, statusCode?: number) {
276
+ super(message);
277
+ this.name = "RelayHttpNonRetryableError";
278
+ this.statusCode = statusCode;
279
+ }
280
+ }
281
+ `;
282
+ await (0, file_utils_1.writeFile)(path.join(eventBackbonePath, 'event-backbone.errors.ts'), errorsContent);
283
+ const eventStoreEntityContent = `import {
284
+ Column,
285
+ CreateDateColumn,
286
+ Entity,
287
+ Index,
288
+ PrimaryGeneratedColumn,
289
+ Unique,
290
+ } from "typeorm";
291
+
292
+ @Entity({ name: "event_store" })
293
+ @Unique("uq_event_store_stream_version", ["streamId", "version"])
294
+ @Index("idx_event_store_stream", ["streamId"])
295
+ @Index("idx_event_store_type", ["type"])
296
+ @Index("idx_event_store_aggregate", ["aggregateType", "aggregateId"])
297
+ export class EventStoreOrmEntity {
298
+ @PrimaryGeneratedColumn("increment", { type: "bigint", name: "global_seq" })
299
+ globalSeq!: string;
300
+
301
+ @Column({ type: "uuid", name: "event_id" })
302
+ eventId!: string;
303
+
304
+ @Column({ type: "varchar", length: 96, nullable: true })
305
+ reference!: string | null;
306
+
307
+ @Column({ type: "varchar", length: 160, name: "stream_id" })
308
+ streamId!: string;
309
+
310
+ @Column({ type: "int" })
311
+ version!: number;
312
+
313
+ @Column({ type: "varchar", length: 160 })
314
+ type!: string;
315
+
316
+ @Column({ type: "varchar", length: 96, name: "aggregate_type" })
317
+ aggregateType!: string;
318
+
319
+ @Column({ type: "varchar", length: 96, name: "aggregate_id" })
320
+ aggregateId!: string;
321
+
322
+ @Column({ type: "jsonb" })
323
+ payload!: Record<string, unknown>;
324
+
325
+ @Column({ type: "jsonb", default: () => "'{}'::jsonb" })
326
+ metadata!: Record<string, unknown>;
327
+
328
+ @Column({ type: "timestamptz", name: "occurred_at" })
329
+ occurredAt!: Date;
330
+
331
+ @CreateDateColumn({ type: "timestamptz", name: "created_at" })
332
+ createdAt!: Date;
333
+ }
334
+ `;
335
+ await (0, file_utils_1.writeFile)(path.join(entitiesPath, 'event-store.orm-entity.ts'), eventStoreEntityContent);
336
+ const outboxEntityContent = `import {
337
+ Column,
338
+ CreateDateColumn,
339
+ Entity,
340
+ Index,
341
+ PrimaryColumn,
342
+ UpdateDateColumn,
343
+ } from "typeorm";
344
+ import { OutboxEventStatus } from "../event-backbone.types";
345
+
346
+ @Entity({ name: "outbox_events" })
347
+ @Index("idx_outbox_events_status_available", ["status", "availableAt"])
348
+ @Index("idx_outbox_events_aggregate", ["aggregateType", "aggregateId"])
349
+ @Index("idx_outbox_events_reference", ["reference"], {
350
+ unique: true,
351
+ where: '"reference" IS NOT NULL',
352
+ })
353
+ export class OutboxEventOrmEntity {
354
+ @PrimaryColumn({ type: "uuid" })
355
+ id!: string;
356
+
357
+ @Column({ type: "varchar", length: 96, nullable: true })
358
+ reference!: string | null;
359
+
360
+ @Column({ type: "varchar", length: 160, name: "event_type" })
361
+ eventType!: string;
362
+
363
+ @Column({ type: "varchar", length: 160 })
364
+ source!: string;
365
+
366
+ @Column({ type: "varchar", length: 96, name: "aggregate_type" })
367
+ aggregateType!: string;
368
+
369
+ @Column({ type: "varchar", length: 96, name: "aggregate_id" })
370
+ aggregateId!: string;
371
+
372
+ @Column({ type: "varchar", length: 160, name: "stream_id" })
373
+ streamId!: string;
374
+
375
+ @Column({ type: "int" })
376
+ version!: number;
377
+
378
+ @Column({ type: "varchar", length: 160, nullable: true })
379
+ subject!: string | null;
380
+
381
+ @Column({ type: "jsonb" })
382
+ payload!: Record<string, unknown>;
383
+
384
+ @Column({ type: "jsonb", default: () => "'{}'::jsonb" })
385
+ metadata!: Record<string, unknown>;
386
+
387
+ @Column({
388
+ type: "varchar",
389
+ length: 20,
390
+ default: OutboxEventStatus.Pending,
391
+ })
392
+ status!: OutboxEventStatus;
393
+
394
+ @Column({ type: "int", default: 0 })
395
+ attempts!: number;
396
+
397
+ @Column({ type: "timestamptz", name: "available_at" })
398
+ availableAt!: Date;
399
+
400
+ @Column({ type: "timestamptz", name: "occurred_at" })
401
+ occurredAt!: Date;
402
+
403
+ @Column({ type: "timestamptz", name: "published_at", nullable: true })
404
+ publishedAt!: Date | null;
405
+
406
+ @Column({ type: "text", name: "last_error", nullable: true })
407
+ lastError!: string | null;
408
+
409
+ @CreateDateColumn({ type: "timestamptz", name: "created_at" })
410
+ createdAt!: Date;
411
+
412
+ @UpdateDateColumn({ type: "timestamptz", name: "updated_at" })
413
+ updatedAt!: Date;
414
+ }
415
+ `;
416
+ await (0, file_utils_1.writeFile)(path.join(entitiesPath, 'outbox-event.orm-entity.ts'), outboxEntityContent);
417
+ const checkpointEntityContent = `import { Column, Entity, PrimaryColumn, UpdateDateColumn } from "typeorm";
418
+
419
+ @Entity({ name: "projection_checkpoints" })
420
+ export class ProjectionCheckpointOrmEntity {
421
+ @PrimaryColumn({ type: "varchar", length: 120 })
422
+ projection!: string;
423
+
424
+ @Column({ type: "bigint", name: "last_seq", default: "0" })
425
+ lastSeq!: string;
426
+
427
+ @UpdateDateColumn({ type: "timestamptz", name: "updated_at" })
428
+ updatedAt!: Date;
429
+ }
430
+ `;
431
+ await (0, file_utils_1.writeFile)(path.join(entitiesPath, 'projection-checkpoint.orm-entity.ts'), checkpointEntityContent);
432
+ const eventStoreServiceContent = `import { randomUUID } from "crypto";
433
+ import { Injectable } from "@nestjs/common";
434
+ import { DataSource, EntityManager, MoreThan } from "typeorm";
435
+ import {
436
+ AppendDomainEventsInput,
437
+ StoredDomainEvent,
438
+ } from "./event-backbone.types";
439
+ import { EventStreamConcurrencyError } from "./event-backbone.errors";
440
+ import { EventStoreOrmEntity } from "./entities/event-store.orm-entity";
441
+ import { ProjectionCheckpointOrmEntity } from "./entities/projection-checkpoint.orm-entity";
442
+
443
+ @Injectable()
444
+ export class EventStoreService {
445
+ constructor(private readonly dataSource: DataSource) {}
446
+
447
+ async append(
448
+ input: AppendDomainEventsInput,
449
+ manager: EntityManager,
450
+ ): Promise<StoredDomainEvent[]> {
451
+ this.assertActiveTransaction(manager);
452
+
453
+ const currentVersion = await this.currentVersion(input.streamId, manager);
454
+ if (
455
+ typeof input.expectedVersion === "number" &&
456
+ input.expectedVersion !== currentVersion
457
+ ) {
458
+ throw new EventStreamConcurrencyError(
459
+ input.streamId,
460
+ input.expectedVersion,
461
+ currentVersion,
462
+ );
463
+ }
464
+
465
+ const repository = manager.getRepository(EventStoreOrmEntity);
466
+ const entities = input.events.map((event, index) =>
467
+ repository.create({
468
+ eventId: randomUUID(),
469
+ reference: event.reference ?? null,
470
+ streamId: input.streamId,
471
+ version: currentVersion + index + 1,
472
+ type: event.type,
473
+ aggregateType: event.aggregateType,
474
+ aggregateId: event.aggregateId,
475
+ payload: event.payload,
476
+ metadata: event.metadata ?? {},
477
+ occurredAt: event.occurredAt ?? new Date(),
478
+ }),
479
+ );
480
+
481
+ try {
482
+ const saved = await repository.save(entities);
483
+ return saved.map((entity) => this.toStoredEvent(entity));
484
+ } catch (error) {
485
+ if (!this.isUniqueViolation(error)) {
486
+ throw error;
487
+ }
488
+
489
+ throw new EventStreamConcurrencyError(
490
+ input.streamId,
491
+ input.expectedVersion,
492
+ currentVersion,
493
+ );
494
+ }
495
+ }
496
+
497
+ async currentVersion(streamId: string, manager?: EntityManager): Promise<number> {
498
+ const repository = (manager ?? this.dataSource.manager).getRepository(EventStoreOrmEntity);
499
+ const latest = await repository.findOne({
500
+ where: { streamId },
501
+ order: { version: "DESC" },
502
+ });
503
+
504
+ return latest?.version ?? 0;
505
+ }
506
+
507
+ async readStream(streamId: string): Promise<StoredDomainEvent[]> {
508
+ const events = await this.dataSource.getRepository(EventStoreOrmEntity).find({
509
+ where: { streamId },
510
+ order: { version: "ASC" },
511
+ });
512
+
513
+ return events.map((event) => this.toStoredEvent(event));
514
+ }
515
+
516
+ async readAfter(globalSeq: string, limit = 100): Promise<StoredDomainEvent[]> {
517
+ const events = await this.dataSource.getRepository(EventStoreOrmEntity).find({
518
+ where: { globalSeq: MoreThan(globalSeq) },
519
+ order: { globalSeq: "ASC" },
520
+ take: limit,
521
+ });
522
+
523
+ return events.map((event) => this.toStoredEvent(event));
524
+ }
525
+
526
+ async checkpoint(projection: string, lastSeq: string): Promise<void> {
527
+ await this.dataSource.getRepository(ProjectionCheckpointOrmEntity).upsert(
528
+ { projection, lastSeq },
529
+ ["projection"],
530
+ );
531
+ }
532
+
533
+ async checkpointFor(projection: string): Promise<string> {
534
+ const checkpoint = await this.dataSource
535
+ .getRepository(ProjectionCheckpointOrmEntity)
536
+ .findOne({ where: { projection } });
537
+
538
+ return checkpoint?.lastSeq ?? "0";
539
+ }
540
+
541
+ private assertActiveTransaction(manager: EntityManager): void {
542
+ if (!manager.queryRunner?.isTransactionActive) {
543
+ throw new Error("Event store append must run inside an active transaction");
544
+ }
545
+ }
546
+
547
+ private isUniqueViolation(error: unknown): boolean {
548
+ return (
549
+ typeof error === "object" &&
550
+ error !== null &&
551
+ "code" in error &&
552
+ (error as { code?: string }).code === "23505"
553
+ );
554
+ }
555
+
556
+ private toStoredEvent(entity: EventStoreOrmEntity): StoredDomainEvent {
557
+ return {
558
+ globalSeq: entity.globalSeq,
559
+ eventId: entity.eventId,
560
+ reference: entity.reference,
561
+ streamId: entity.streamId,
562
+ version: entity.version,
563
+ type: entity.type,
564
+ aggregateType: entity.aggregateType,
565
+ aggregateId: entity.aggregateId,
566
+ payload: entity.payload,
567
+ metadata: entity.metadata,
568
+ occurredAt: entity.occurredAt,
569
+ createdAt: entity.createdAt,
570
+ };
571
+ }
572
+ }
573
+ `;
574
+ await (0, file_utils_1.writeFile)(path.join(eventBackbonePath, 'event-store.service.ts'), eventStoreServiceContent);
575
+ const outboxServiceContent = `import { Injectable } from "@nestjs/common";
576
+ import { DataSource, EntityManager } from "typeorm";
577
+ import {
578
+ OutboxEventStatus,
579
+ OutboxPublishInput,
580
+ } from "./event-backbone.types";
581
+ import { OutboxTransactionRequiredError } from "./event-backbone.errors";
582
+ import { OutboxEventOrmEntity } from "./entities/outbox-event.orm-entity";
583
+
584
+ @Injectable()
585
+ export class OutboxService {
586
+ constructor(private readonly dataSource: DataSource) {}
587
+
588
+ async enqueue(
589
+ input: OutboxPublishInput,
590
+ manager: EntityManager,
591
+ ): Promise<OutboxEventOrmEntity> {
592
+ if (!manager.queryRunner?.isTransactionActive) {
593
+ throw new OutboxTransactionRequiredError();
594
+ }
595
+
596
+ const now = new Date();
597
+ const repository = manager.getRepository(OutboxEventOrmEntity);
598
+ const event = repository.create({
599
+ id: input.eventId,
600
+ reference: input.reference ?? null,
601
+ eventType: input.type,
602
+ source: input.source,
603
+ aggregateType: input.aggregateType,
604
+ aggregateId: input.aggregateId,
605
+ streamId: input.streamId,
606
+ version: input.version,
607
+ subject: input.subject ?? null,
608
+ payload: input.payload,
609
+ metadata: input.metadata ?? {},
610
+ status: OutboxEventStatus.Pending,
611
+ attempts: 0,
612
+ availableAt: now,
613
+ occurredAt: input.occurredAt ?? now,
614
+ publishedAt: null,
615
+ lastError: null,
616
+ });
617
+
618
+ return repository.save(event);
619
+ }
620
+
621
+ async enqueueInTransaction<T>(
622
+ work: (manager: EntityManager) => Promise<{ result: T; event: OutboxPublishInput }>,
623
+ ): Promise<T> {
624
+ return this.dataSource.transaction(async (manager) => {
625
+ const { result, event } = await work(manager);
626
+ await this.enqueue(event, manager);
627
+ return result;
628
+ });
629
+ }
630
+ }
631
+ `;
632
+ await (0, file_utils_1.writeFile)(path.join(eventBackbonePath, 'outbox.service.ts'), outboxServiceContent);
633
+ const noopPublisherContent = `import { Injectable, Logger } from "@nestjs/common";
634
+ import { EventBackboneEnvelope, EventBackbonePublisher } from "../event-backbone.types";
635
+
636
+ @Injectable()
637
+ export class NoopEventPublisher implements EventBackbonePublisher {
638
+ private readonly logger = new Logger(NoopEventPublisher.name);
639
+
640
+ async publish(envelope: EventBackboneEnvelope): Promise<void> {
641
+ this.logger.debug(
642
+ "Event backbone publish skipped because transport is none: " + envelope.type,
643
+ );
644
+ }
645
+ }
646
+ `;
647
+ await (0, file_utils_1.writeFile)(path.join(publishersPath, 'noop-event.publisher.ts'), noopPublisherContent);
648
+ const relayHttpPublisherContent = `import { Injectable } from "@nestjs/common";
649
+ import { ConfigService } from "@nestjs/config";
650
+ import { EVENT_BACKBONE_ENV } from "../event-backbone.constants";
651
+ import {
652
+ RelayHttpNonRetryableError,
653
+ RelayHttpRetryableError,
654
+ } from "../event-backbone.errors";
655
+ import { EventBackboneEnvelope, EventBackbonePublisher } from "../event-backbone.types";
656
+
657
+ const RELAY_EVENTS_PATH = "/api/v1/events";
658
+ const RELAY_AUDIENCE_HEADER = "X-Relay-Audience";
659
+ const CORRELATION_HEADER = "X-Correlation-Id";
660
+
661
+ @Injectable()
662
+ export class RelayHttpPublisher implements EventBackbonePublisher {
663
+ constructor(private readonly configService: ConfigService) {}
664
+
665
+ async publish(envelope: EventBackboneEnvelope): Promise<void> {
666
+ const relayUrl = this.requireConfig(EVENT_BACKBONE_ENV.relayHttpUrl, "relay URL");
667
+ const machineToken = this.requireConfig(
668
+ EVENT_BACKBONE_ENV.relayServiceCredential,
669
+ "relay machine token",
670
+ );
671
+ const audience = this.requireConfig(EVENT_BACKBONE_ENV.relayAudience, "relay audience");
672
+ const timeoutMs = this.timeoutMs();
673
+ const url = this.joinUrl(relayUrl, RELAY_EVENTS_PATH);
674
+
675
+ const headers: Record<string, string> = {
676
+ Authorization: "Bearer " + machineToken,
677
+ [RELAY_AUDIENCE_HEADER]: audience,
678
+ "Content-Type": "application/json",
679
+ "Idempotency-Key": envelope.id,
680
+ };
681
+
682
+ const correlationId = envelope.metadata?.correlationId;
683
+ if (typeof correlationId === "string" && correlationId.length > 0) {
684
+ headers[CORRELATION_HEADER] = correlationId;
685
+ }
686
+
687
+ let response: Response;
688
+ try {
689
+ response = await fetch(url, {
690
+ method: "POST",
691
+ headers,
692
+ body: JSON.stringify({
693
+ ...envelope,
694
+ reference: envelope.reference || envelope.id,
695
+ }),
696
+ signal: AbortSignal.timeout(timeoutMs),
697
+ });
698
+ } catch (error) {
699
+ if (this.isTimeout(error)) {
700
+ throw new RelayHttpRetryableError(
701
+ "Relay HTTP request timed out after " + String(timeoutMs) + "ms",
702
+ );
703
+ }
704
+
705
+ throw new RelayHttpRetryableError(
706
+ error instanceof Error ? error.message : "Relay HTTP request failed",
707
+ );
708
+ }
709
+
710
+ if (response.status >= 200 && response.status < 300) {
711
+ return;
712
+ }
713
+
714
+ const body = await response.text().catch(() => "");
715
+ const message =
716
+ "Relay HTTP publish failed with status " +
717
+ String(response.status) +
718
+ (body ? ": " + body.slice(0, 500) : "");
719
+
720
+ if (this.isRetryableStatus(response.status)) {
721
+ throw new RelayHttpRetryableError(message, response.status);
722
+ }
723
+
724
+ throw new RelayHttpNonRetryableError(message, response.status);
725
+ }
726
+
727
+ private requireConfig(key: string, label: string): string {
728
+ const value = this.configService.get<string>(key);
729
+ if (typeof value === "string" && value.trim().length > 0) {
730
+ return value.trim();
731
+ }
732
+
733
+ throw new RelayHttpNonRetryableError("Missing event backbone " + label + " (" + key + ")");
734
+ }
735
+
736
+ private timeoutMs(): number {
737
+ const configured = Number(this.configService.get<string>(EVENT_BACKBONE_ENV.relayTimeoutMs));
738
+ return Number.isFinite(configured) && configured >= 1000 ? Math.floor(configured) : 10000;
739
+ }
740
+
741
+ private isRetryableStatus(status: number): boolean {
742
+ return status === 408 || status === 425 || status === 429 || status >= 500;
743
+ }
744
+
745
+ private isTimeout(error: unknown): boolean {
746
+ return (
747
+ error instanceof Error &&
748
+ (error.name === "TimeoutError" || error.name === "AbortError")
749
+ );
750
+ }
751
+
752
+ private joinUrl(baseUrl: string, path: string): string {
753
+ return baseUrl.replace(/\\/+$/, "") + path;
754
+ }
755
+ }
756
+ `;
757
+ await (0, file_utils_1.writeFile)(path.join(publishersPath, 'relay-http.publisher.ts'), relayHttpPublisherContent);
758
+ const pulsarPublisherContent = `import { Injectable, Logger, OnModuleDestroy } from "@nestjs/common";
759
+ import { ConfigService } from "@nestjs/config";
760
+ import { EventBackboneEnvelope, EventBackbonePublisher } from "../event-backbone.types";
761
+ import { EVENT_BACKBONE_ENV } from "../event-backbone.constants";
762
+
763
+ type PulsarModule = typeof import("pulsar-client");
764
+ type PulsarClient = import("pulsar-client").Client;
765
+ type PulsarProducer = import("pulsar-client").Producer;
766
+
767
+ @Injectable()
768
+ export class PulsarEventPublisher implements EventBackbonePublisher, OnModuleDestroy {
769
+ private readonly logger = new Logger(PulsarEventPublisher.name);
770
+ private pulsar?: PulsarModule;
771
+ private client?: PulsarClient;
772
+ private readonly producers = new Map<string, PulsarProducer>();
773
+
774
+ constructor(private readonly configService: ConfigService) {}
775
+
776
+ async publish(envelope: EventBackboneEnvelope): Promise<void> {
777
+ const producer = await this.producerFor(envelope);
778
+ await producer.send({
779
+ data: Buffer.from(JSON.stringify(envelope)),
780
+ eventTimestamp: Date.parse(envelope.occurredAt),
781
+ properties: {
782
+ event_id: envelope.id,
783
+ event_type: envelope.type,
784
+ aggregate_type: envelope.aggregateType,
785
+ aggregate_id: envelope.aggregateId,
786
+ reference: envelope.reference ?? "",
787
+ },
788
+ });
789
+ }
790
+
791
+ async onModuleDestroy(): Promise<void> {
792
+ for (const producer of this.producers.values()) {
793
+ await producer.close();
794
+ }
795
+ this.producers.clear();
796
+ await this.client?.close();
797
+ }
798
+
799
+ private async producerFor(envelope: EventBackboneEnvelope): Promise<PulsarProducer> {
800
+ const topic = this.topicFor(envelope);
801
+ const cached = this.producers.get(topic);
802
+ if (cached) {
803
+ return cached;
804
+ }
805
+
806
+ const client = await this.getClient();
807
+ const producer = await client.createProducer({ topic });
808
+ this.producers.set(topic, producer);
809
+ return producer;
810
+ }
811
+
812
+ private async getClient(): Promise<PulsarClient> {
813
+ if (this.client) {
814
+ return this.client;
815
+ }
816
+
817
+ this.pulsar = await import("pulsar-client");
818
+ const serviceUrl =
819
+ this.configService.get<string>(EVENT_BACKBONE_ENV.pulsarServiceUrl) ??
820
+ "pulsar://localhost:6650";
821
+
822
+ this.logger.log("Connecting event backbone to Pulsar at " + serviceUrl);
823
+ this.client = new this.pulsar.Client({ serviceUrl });
824
+ return this.client;
825
+ }
826
+
827
+ private topicFor(envelope: EventBackboneEnvelope): string {
828
+ const tenant = this.configService.get<string>(EVENT_BACKBONE_ENV.pulsarTenant) ?? "public";
829
+ const namespace =
830
+ this.configService.get<string>(EVENT_BACKBONE_ENV.pulsarNamespace) ?? "default";
831
+ const prefix = this.configService.get<string>(EVENT_BACKBONE_ENV.pulsarTopicPrefix) ?? "";
832
+ const eventType = envelope.type.replace(/[^a-zA-Z0-9._-]/g, ".").toLowerCase();
833
+
834
+ return "persistent://" + tenant + "/" + namespace + "/" + prefix + eventType;
835
+ }
836
+ }
837
+ `;
838
+ await (0, file_utils_1.writeFile)(path.join(publishersPath, 'pulsar-event.publisher.ts'), pulsarPublisherContent);
839
+ const relayServiceContent = `import { Inject, Injectable, Logger } from "@nestjs/common";
840
+ import { ConfigService } from "@nestjs/config";
841
+ import { Interval } from "@nestjs/schedule";
842
+ import { DataSource } from "typeorm";
843
+ import {
844
+ EVENT_BACKBONE_ENV,
845
+ EVENT_BACKBONE_PUBLISHER,
846
+ EVENT_BACKBONE_TRANSPORT,
847
+ } from "./event-backbone.constants";
848
+ import type {
849
+ EventBackboneEnvelope,
850
+ EventBackbonePublisher,
851
+ } from "./event-backbone.types";
852
+ import { OutboxEventStatus } from "./event-backbone.types";
853
+ import { OutboxEventOrmEntity } from "./entities/outbox-event.orm-entity";
854
+
855
+ interface ClaimedOutboxRow {
856
+ id: string;
857
+ reference: string | null;
858
+ event_type: string;
859
+ source: string;
860
+ aggregate_type: string;
861
+ aggregate_id: string;
862
+ stream_id: string;
863
+ version: number;
864
+ subject: string | null;
865
+ payload: Record<string, unknown>;
866
+ metadata: Record<string, unknown>;
867
+ attempts: number;
868
+ occurred_at: Date | string;
869
+ }
870
+
871
+ @Injectable()
872
+ export class OutboxRelayService {
873
+ private readonly logger = new Logger(OutboxRelayService.name);
874
+ private running = false;
875
+
876
+ constructor(
877
+ private readonly dataSource: DataSource,
878
+ private readonly configService: ConfigService,
879
+ @Inject(EVENT_BACKBONE_PUBLISHER)
880
+ private readonly publisher: EventBackbonePublisher,
881
+ ) {}
882
+
883
+ @Interval(1000)
884
+ async flush(): Promise<void> {
885
+ if (this.running || !this.enabled()) {
886
+ return;
887
+ }
888
+
889
+ this.running = true;
890
+ try {
891
+ const rows = await this.claimBatch();
892
+ for (const row of rows) {
893
+ await this.publishRow(row);
894
+ }
895
+ } finally {
896
+ this.running = false;
897
+ }
898
+ }
899
+
900
+ private enabled(): boolean {
901
+ if (!this.booleanEnv(EVENT_BACKBONE_ENV.relayEnabled, true)) {
902
+ return false;
903
+ }
904
+
905
+ const transport =
906
+ this.configService.get<string>(EVENT_BACKBONE_ENV.transport) ??
907
+ EVENT_BACKBONE_TRANSPORT.none;
908
+
909
+ return transport !== EVENT_BACKBONE_TRANSPORT.none;
910
+ }
911
+
912
+ private async claimBatch(): Promise<ClaimedOutboxRow[]> {
913
+ const batchSize = this.integerEnv(EVENT_BACKBONE_ENV.relayBatchSize, 50);
914
+ const maxAttempts = this.integerEnv(EVENT_BACKBONE_ENV.relayMaxAttempts, 10);
915
+
916
+ return this.dataSource.transaction(async (manager) => {
917
+ const result = await manager.query(
918
+ "UPDATE outbox_events " +
919
+ "SET status = $1, attempts = attempts + 1, updated_at = now() " +
920
+ "WHERE id IN (" +
921
+ "SELECT id FROM outbox_events " +
922
+ "WHERE ((status IN ($2, $3) AND available_at <= now()) " +
923
+ "OR (status = $4 AND updated_at < now() - interval '5 minutes')) " +
924
+ "AND attempts < $5 " +
925
+ "ORDER BY available_at ASC " +
926
+ "LIMIT $6 " +
927
+ "FOR UPDATE SKIP LOCKED" +
928
+ ") RETURNING *",
929
+ [
930
+ OutboxEventStatus.Publishing,
931
+ OutboxEventStatus.Pending,
932
+ OutboxEventStatus.Failed,
933
+ OutboxEventStatus.Publishing,
934
+ maxAttempts,
935
+ batchSize,
936
+ ],
937
+ );
938
+
939
+ const rows = Array.isArray(result[0]) ? result[0] : result;
940
+ return rows as ClaimedOutboxRow[];
941
+ });
942
+ }
943
+
944
+ private async publishRow(row: ClaimedOutboxRow): Promise<void> {
945
+ const repository = this.dataSource.getRepository(OutboxEventOrmEntity);
946
+
947
+ try {
948
+ await this.publisher.publish(this.envelopeFromRow(row));
949
+ await repository.update(row.id, {
950
+ status: OutboxEventStatus.Published,
951
+ publishedAt: new Date(),
952
+ lastError: null,
953
+ });
954
+ } catch (error) {
955
+ const message = error instanceof Error ? error.message : String(error);
956
+ const maxAttempts = this.integerEnv(EVENT_BACKBONE_ENV.relayMaxAttempts, 10);
957
+ const nextStatus =
958
+ row.attempts >= maxAttempts ? OutboxEventStatus.Failed : OutboxEventStatus.Pending;
959
+ const delayMs = this.integerEnv(EVENT_BACKBONE_ENV.relayRetryBaseMs, 30000) * row.attempts;
960
+
961
+ await repository.update(row.id, {
962
+ status: nextStatus,
963
+ availableAt: new Date(Date.now() + delayMs),
964
+ lastError: message,
965
+ });
966
+
967
+ this.logger.warn("Failed to publish outbox event " + row.id + ": " + message);
968
+ }
969
+ }
970
+
971
+ private envelopeFromRow(row: ClaimedOutboxRow): EventBackboneEnvelope {
972
+ const occurredAt =
973
+ row.occurred_at instanceof Date ? row.occurred_at.toISOString() : row.occurred_at;
974
+ const metadata = row.metadata ?? {};
975
+ const schemaVersion =
976
+ typeof metadata.schemaVersion === "number" && Number.isFinite(metadata.schemaVersion)
977
+ ? Math.floor(metadata.schemaVersion)
978
+ : 1;
979
+
980
+ return {
981
+ id: row.id,
982
+ schemaVersion,
983
+ reference: row.reference ?? undefined,
984
+ type: row.event_type,
985
+ source: row.source,
986
+ aggregateType: row.aggregate_type,
987
+ aggregateId: row.aggregate_id,
988
+ streamId: row.stream_id,
989
+ version: row.version,
990
+ subject: row.subject ?? undefined,
991
+ payload: row.payload,
992
+ metadata,
993
+ occurredAt,
994
+ };
995
+ }
996
+
997
+ private integerEnv(key: string, fallback: number): number {
998
+ const value = Number(this.configService.get<string>(key));
999
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
1000
+ }
1001
+
1002
+ private booleanEnv(key: string, fallback: boolean): boolean {
1003
+ const value = this.configService.get<boolean | string>(key, fallback);
1004
+ return value === true || value === "true";
1005
+ }
1006
+ }
1007
+ `;
1008
+ await (0, file_utils_1.writeFile)(path.join(eventBackbonePath, 'outbox-relay.service.ts'), relayServiceContent);
1009
+ const moduleContent = `import { Module } from "@nestjs/common";
1010
+ import { ConfigModule, ConfigService } from "@nestjs/config";
1011
+ import { ScheduleModule } from "@nestjs/schedule";
1012
+ import { TypeOrmModule } from "@nestjs/typeorm";
1013
+ import { BusinessReferenceModule } from "../business-references";
1014
+ import {
1015
+ EVENT_BACKBONE_ENV,
1016
+ EVENT_BACKBONE_PUBLISHER,
1017
+ EVENT_BACKBONE_TRANSPORT,
1018
+ } from "./event-backbone.constants";
1019
+ import { EventStoreService } from "./event-store.service";
1020
+ import { OutboxRelayService } from "./outbox-relay.service";
1021
+ import { OutboxService } from "./outbox.service";
1022
+ import { EventStoreOrmEntity } from "./entities/event-store.orm-entity";
1023
+ import { OutboxEventOrmEntity } from "./entities/outbox-event.orm-entity";
1024
+ import { ProjectionCheckpointOrmEntity } from "./entities/projection-checkpoint.orm-entity";
1025
+ import { NoopEventPublisher } from "./publishers/noop-event.publisher";
1026
+ import { PulsarEventPublisher } from "./publishers/pulsar-event.publisher";
1027
+ import { RelayHttpPublisher } from "./publishers/relay-http.publisher";
1028
+
1029
+ @Module({
1030
+ imports: [
1031
+ ConfigModule,
1032
+ BusinessReferenceModule,
1033
+ ScheduleModule.forRoot(),
1034
+ TypeOrmModule.forFeature([
1035
+ EventStoreOrmEntity,
1036
+ OutboxEventOrmEntity,
1037
+ ProjectionCheckpointOrmEntity,
1038
+ ]),
1039
+ ],
1040
+ providers: [
1041
+ EventStoreService,
1042
+ OutboxService,
1043
+ OutboxRelayService,
1044
+ {
1045
+ provide: EVENT_BACKBONE_PUBLISHER,
1046
+ inject: [ConfigService],
1047
+ useFactory: (configService: ConfigService) => {
1048
+ const transport =
1049
+ configService.get<string>(EVENT_BACKBONE_ENV.transport) ??
1050
+ EVENT_BACKBONE_TRANSPORT.none;
1051
+
1052
+ if (transport === EVENT_BACKBONE_TRANSPORT.pulsar) {
1053
+ return new PulsarEventPublisher(configService);
1054
+ }
1055
+
1056
+ if (transport === EVENT_BACKBONE_TRANSPORT.relayHttp) {
1057
+ return new RelayHttpPublisher(configService);
1058
+ }
1059
+
1060
+ return new NoopEventPublisher();
1061
+ },
1062
+ },
1063
+ ],
1064
+ exports: [EventStoreService, OutboxService, EVENT_BACKBONE_PUBLISHER],
1065
+ })
1066
+ export class EventBackboneModule {}
1067
+ `;
1068
+ await (0, file_utils_1.writeFile)(path.join(eventBackbonePath, 'event-backbone.module.ts'), moduleContent);
1069
+ const indexContent = `export * from "./event-backbone.constants";
1070
+ export * from "./event-backbone.env";
1071
+ export * from "./event-backbone.errors";
1072
+ export * from "./event-backbone.types";
1073
+ export * from "./event-store.service";
1074
+ export * from "./outbox.service";
1075
+ export * from "./event-backbone.module";
1076
+ export * from "./entities/event-store.orm-entity";
1077
+ export * from "./entities/outbox-event.orm-entity";
1078
+ export * from "./entities/projection-checkpoint.orm-entity";
1079
+ `;
1080
+ await (0, file_utils_1.writeFile)(path.join(eventBackbonePath, 'index.ts'), indexContent);
1081
+ const timestamp = Date.now();
1082
+ const migrationName = `CreateEventBackboneTables${timestamp}`;
1083
+ const migrationContent = `import { MigrationInterface, QueryRunner } from "typeorm";
1084
+
1085
+ export class ${migrationName} implements MigrationInterface {
1086
+ name = "${migrationName}";
1087
+
1088
+ public async up(queryRunner: QueryRunner): Promise<void> {
1089
+ await queryRunner.query('CREATE TABLE IF NOT EXISTS "event_store" ("global_seq" bigserial PRIMARY KEY, "event_id" uuid NOT NULL, "reference" varchar(96), "stream_id" varchar(160) NOT NULL, "version" int NOT NULL, "type" varchar(160) NOT NULL, "aggregate_type" varchar(96) NOT NULL, "aggregate_id" varchar(96) NOT NULL, "payload" jsonb NOT NULL, "metadata" jsonb NOT NULL DEFAULT \\'{}\\'::jsonb, "occurred_at" timestamptz NOT NULL, "created_at" timestamptz NOT NULL DEFAULT now(), CONSTRAINT "uq_event_store_stream_version" UNIQUE ("stream_id", "version"))');
1090
+ await queryRunner.query('CREATE INDEX IF NOT EXISTS "idx_event_store_stream" ON "event_store" ("stream_id")');
1091
+ await queryRunner.query('CREATE UNIQUE INDEX IF NOT EXISTS "idx_event_store_event_id" ON "event_store" ("event_id")');
1092
+ await queryRunner.query('CREATE INDEX IF NOT EXISTS "idx_event_store_type" ON "event_store" ("type")');
1093
+ await queryRunner.query('CREATE INDEX IF NOT EXISTS "idx_event_store_aggregate" ON "event_store" ("aggregate_type", "aggregate_id")');
1094
+
1095
+ await queryRunner.query('CREATE TABLE IF NOT EXISTS "outbox_events" ("id" uuid PRIMARY KEY, "reference" varchar(96), "event_type" varchar(160) NOT NULL, "source" varchar(160) NOT NULL, "aggregate_type" varchar(96) NOT NULL, "aggregate_id" varchar(96) NOT NULL, "stream_id" varchar(160) NOT NULL, "version" int NOT NULL, "subject" varchar(160), "payload" jsonb NOT NULL, "metadata" jsonb NOT NULL DEFAULT \\'{}\\'::jsonb, "status" varchar(20) NOT NULL DEFAULT \\'PENDING\\', "attempts" int NOT NULL DEFAULT 0, "available_at" timestamptz NOT NULL, "occurred_at" timestamptz NOT NULL, "published_at" timestamptz, "last_error" text, "created_at" timestamptz NOT NULL DEFAULT now(), "updated_at" timestamptz NOT NULL DEFAULT now(), CONSTRAINT "chk_outbox_events_status" CHECK ("status" IN (\\'PENDING\\', \\'PUBLISHING\\', \\'PUBLISHED\\', \\'FAILED\\')))');
1096
+ await queryRunner.query('CREATE INDEX IF NOT EXISTS "idx_outbox_events_status_available" ON "outbox_events" ("status", "available_at")');
1097
+ await queryRunner.query('CREATE INDEX IF NOT EXISTS "idx_outbox_events_aggregate" ON "outbox_events" ("aggregate_type", "aggregate_id")');
1098
+ await queryRunner.query('CREATE UNIQUE INDEX IF NOT EXISTS "idx_outbox_events_reference" ON "outbox_events" ("reference") WHERE "reference" IS NOT NULL');
1099
+
1100
+ await queryRunner.query('CREATE TABLE IF NOT EXISTS "projection_checkpoints" ("projection" varchar(120) PRIMARY KEY, "last_seq" bigint NOT NULL DEFAULT 0, "updated_at" timestamptz NOT NULL DEFAULT now())');
1101
+ }
1102
+
1103
+ public async down(queryRunner: QueryRunner): Promise<void> {
1104
+ await queryRunner.query('DROP TABLE IF EXISTS "projection_checkpoints"');
1105
+ await queryRunner.query('DROP TABLE IF EXISTS "outbox_events"');
1106
+ await queryRunner.query('DROP TABLE IF EXISTS "event_store"');
1107
+ }
1108
+ }
1109
+ `;
1110
+ await (0, file_utils_1.writeFile)(path.join(migrationsPath, `${timestamp}-CreateEventBackboneTables.ts`), migrationContent);
1111
+ console.log(chalk_1.default.green(' ✓ Business reference sidecar recipe installed'));
1112
+ console.log(chalk_1.default.green(' ✓ Joi environment schema fragment'));
1113
+ console.log(chalk_1.default.green(' ✓ Postgres event store entities and service'));
1114
+ console.log(chalk_1.default.green(' ✓ Transactional outbox service and relay'));
1115
+ console.log(chalk_1.default.green(' ✓ Relay HTTP and Pulsar publishers with transport selection'));
1116
+ console.log(chalk_1.default.green(' ✓ TypeORM migration for event backbone tables'));
1117
+ }
1118
+ //# sourceMappingURL=event-backbone.recipe.js.map