nestjs-ddd-cli 3.2.2 → 3.3.1

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 (210) 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 +54 -28
  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 +7 -2
  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 +4 -2
  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 +229 -85
  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 +11 -0
  70. package/dist/commands/recipes/index.js +11 -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.js +1 -1
  76. package/dist/commands/recipes/kafka-consumer.recipe.d.ts +1 -0
  77. package/dist/commands/recipes/kafka-consumer.recipe.js +65 -0
  78. package/dist/commands/recipes/kafka-consumer.recipe.js.map +1 -0
  79. package/dist/commands/recipes/oauth2-client-credentials.recipe.d.ts +1 -0
  80. package/dist/commands/recipes/oauth2-client-credentials.recipe.js +60 -0
  81. package/dist/commands/recipes/oauth2-client-credentials.recipe.js.map +1 -0
  82. package/dist/commands/recipes/oauth2-token-introspection.recipe.d.ts +1 -0
  83. package/dist/commands/recipes/oauth2-token-introspection.recipe.js +67 -0
  84. package/dist/commands/recipes/oauth2-token-introspection.recipe.js.map +1 -0
  85. package/dist/commands/recipes/oidc-bff-session.recipe.d.ts +1 -0
  86. package/dist/commands/recipes/oidc-bff-session.recipe.js +57 -0
  87. package/dist/commands/recipes/oidc-bff-session.recipe.js.map +1 -0
  88. package/dist/commands/recipes/oidc-dashboard.recipe.d.ts +1 -0
  89. package/dist/commands/recipes/oidc-dashboard.recipe.js +463 -0
  90. package/dist/commands/recipes/oidc-dashboard.recipe.js.map +1 -0
  91. package/dist/commands/recipes/oidc-resource-server.recipe.d.ts +1 -0
  92. package/dist/commands/recipes/oidc-resource-server.recipe.js +85 -0
  93. package/dist/commands/recipes/oidc-resource-server.recipe.js.map +1 -0
  94. package/dist/commands/recipes/platform-context.recipe.d.ts +1 -0
  95. package/dist/commands/recipes/platform-context.recipe.js +229 -0
  96. package/dist/commands/recipes/platform-context.recipe.js.map +1 -0
  97. package/dist/commands/recipes/platform-parc-authorization.recipe.d.ts +1 -0
  98. package/dist/commands/recipes/platform-parc-authorization.recipe.js +339 -0
  99. package/dist/commands/recipes/platform-parc-authorization.recipe.js.map +1 -0
  100. package/dist/commands/recipes/platform-parc-bound-authorization.recipe.d.ts +1 -0
  101. package/dist/commands/recipes/platform-parc-bound-authorization.recipe.js +56 -0
  102. package/dist/commands/recipes/platform-parc-bound-authorization.recipe.js.map +1 -0
  103. package/dist/commands/recipes/platform-service-access-request-context.recipe.d.ts +1 -0
  104. package/dist/commands/recipes/platform-service-access-request-context.recipe.js +103 -0
  105. package/dist/commands/recipes/platform-service-access-request-context.recipe.js.map +1 -0
  106. package/dist/commands/recipes/platform-service-runtime.recipe.d.ts +1 -0
  107. package/dist/commands/recipes/platform-service-runtime.recipe.js +415 -0
  108. package/dist/commands/recipes/platform-service-runtime.recipe.js.map +1 -0
  109. package/dist/index.js +77 -9
  110. package/dist/index.js.map +1 -1
  111. package/dist/templates/ai-context/CLAUDE.md.hbs +39 -3
  112. package/dist/templates/ai-context/conventions.md.hbs +13 -8
  113. package/dist/templates/command/create-command.hbs +8 -3
  114. package/dist/templates/command/delete-command.hbs +1 -1
  115. package/dist/templates/command/update-command.hbs +1 -1
  116. package/dist/templates/controller/application-controller.hbs +7 -0
  117. package/dist/templates/controller/controller.hbs +6 -0
  118. package/dist/templates/dto/create-dto.hbs +17 -5
  119. package/dist/templates/dto/filter-dto.hbs +1 -1
  120. package/dist/templates/dto/response-dto.hbs +1 -1
  121. package/dist/templates/entity/entity.hbs +4 -0
  122. package/dist/templates/mapper/mapper.hbs +5 -1
  123. package/dist/templates/orm-entity/orm-entity.hbs +9 -1
  124. package/dist/templates/prisma/prisma-mapper.hbs +6 -0
  125. package/dist/templates/prisma/prisma-repository.hbs +57 -5
  126. package/dist/templates/prisma/prisma-schema.hbs +2 -0
  127. package/dist/templates/query/get-all.query.hbs +10 -2
  128. package/dist/templates/recipes/bounded-json-http/README.md.hbs +36 -0
  129. package/dist/templates/recipes/external-projection-worker/README.md.hbs +157 -0
  130. package/dist/templates/recipes/external-projection-worker/canonical-json.ts.hbs +123 -0
  131. package/dist/templates/recipes/external-projection-worker/enqueuer.ts.hbs +167 -0
  132. package/dist/templates/recipes/external-projection-worker/index.ts.hbs +6 -0
  133. package/dist/templates/recipes/external-projection-worker/migration.ts.hbs +88 -0
  134. package/dist/templates/recipes/external-projection-worker/module.ts.hbs +36 -0
  135. package/dist/templates/recipes/external-projection-worker/store.ts.hbs +354 -0
  136. package/dist/templates/recipes/external-projection-worker/types.ts.hbs +134 -0
  137. package/dist/templates/recipes/external-projection-worker/worker.ts.hbs +296 -0
  138. package/dist/templates/recipes/kafka-consumer/README.md.hbs +50 -0
  139. package/dist/templates/recipes/kafka-consumer/kafka-consumer.ts.hbs +135 -0
  140. package/dist/templates/recipes/kafka-consumer/migration.ts.hbs +17 -0
  141. package/dist/templates/recipes/oauth2-client-credentials/README.md.hbs +40 -0
  142. package/dist/templates/recipes/oauth2-client-credentials/client.spec.ts.hbs +234 -0
  143. package/dist/templates/recipes/oauth2-client-credentials/client.ts.hbs +210 -0
  144. package/dist/templates/recipes/oauth2-client-credentials/index.ts.hbs +2 -0
  145. package/dist/templates/recipes/oauth2-client-credentials/module.ts.hbs +58 -0
  146. package/dist/templates/recipes/oauth2-token-introspection/README.md.hbs +158 -0
  147. package/dist/templates/recipes/oauth2-token-introspection/client.spec.ts.hbs +350 -0
  148. package/dist/templates/recipes/oauth2-token-introspection/client.ts.hbs +241 -0
  149. package/dist/templates/recipes/oauth2-token-introspection/http-client.spec.ts.hbs +411 -0
  150. package/dist/templates/recipes/oauth2-token-introspection/http-client.ts.hbs +343 -0
  151. package/dist/templates/recipes/oauth2-token-introspection/index.ts.hbs +3 -0
  152. package/dist/templates/recipes/oauth2-token-introspection/module.ts.hbs +58 -0
  153. package/dist/templates/recipes/oidc-bff-session/README.md.hbs +96 -0
  154. package/dist/templates/recipes/oidc-bff-session/client.test.ts.hbs +663 -0
  155. package/dist/templates/recipes/oidc-bff-session/client.ts.hbs +635 -0
  156. package/dist/templates/recipes/oidc-bff-session/index.ts.hbs +3 -0
  157. package/dist/templates/recipes/oidc-bff-session/store.integration.test.ts.hbs +480 -0
  158. package/dist/templates/recipes/oidc-bff-session/store.test.ts.hbs +87 -0
  159. package/dist/templates/recipes/oidc-bff-session/store.ts.hbs +549 -0
  160. package/dist/templates/recipes/oidc-bff-session/valkey.test.ts.hbs +158 -0
  161. package/dist/templates/recipes/oidc-bff-session/valkey.ts.hbs +148 -0
  162. package/dist/templates/recipes/oidc-resource-server/README.md.hbs +74 -0
  163. package/dist/templates/recipes/oidc-resource-server/index.ts.hbs +2 -0
  164. package/dist/templates/recipes/oidc-resource-server/module.ts.hbs +51 -0
  165. package/dist/templates/recipes/oidc-resource-server/verifier.spec.ts.hbs +186 -0
  166. package/dist/templates/recipes/oidc-resource-server/verifier.ts.hbs +440 -0
  167. package/dist/templates/recipes/platform-parc-bound-authorization/README.md.hbs +92 -0
  168. package/dist/templates/recipes/platform-parc-bound-authorization/client.ts.hbs +142 -0
  169. package/dist/templates/recipes/platform-service-access-request-context/docs.md.hbs +61 -0
  170. package/dist/templates/recipes/platform-service-access-request-context/env.example.hbs +7 -0
  171. package/dist/templates/recipes/platform-service-access-request-context/index.ts.hbs +7 -0
  172. package/dist/templates/recipes/platform-service-access-request-context/request-context-headers.ts.hbs +61 -0
  173. package/dist/templates/recipes/platform-service-access-request-context/request-context-introspection.parser.ts.hbs +132 -0
  174. package/dist/templates/recipes/platform-service-access-request-context/request-context-introspection.types.ts.hbs +40 -0
  175. package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.client.ts.hbs +45 -0
  176. package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.config.ts.hbs +142 -0
  177. package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.guard.ts.hbs +77 -0
  178. package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.module.ts.hbs +17 -0
  179. package/dist/templates/repository/repository.hbs +42 -4
  180. package/dist/templates/resolver/graphql-input.hbs +23 -6
  181. package/dist/templates/resolver/resolver.hbs +4 -0
  182. package/dist/templates/service/domain-service.hbs +2 -2
  183. package/dist/templates/test/controller.spec.hbs +5 -1
  184. package/dist/templates/test/prisma-repository.spec.hbs +192 -0
  185. package/dist/templates/test/repository.spec.hbs +40 -1
  186. package/dist/templates/test/usecase.spec.hbs +23 -4
  187. package/dist/templates/usecase/application-usecase.hbs +17 -0
  188. package/dist/templates/usecase/create-usecase.hbs +34 -3
  189. package/dist/templates/usecase/update-usecase.hbs +15 -1
  190. package/dist/utils/config.utils.d.ts +2 -0
  191. package/dist/utils/config.utils.js +14 -2
  192. package/dist/utils/config.utils.js.map +1 -1
  193. package/dist/utils/dependency.utils.d.ts +16 -2
  194. package/dist/utils/dependency.utils.js +100 -12
  195. package/dist/utils/dependency.utils.js.map +1 -1
  196. package/dist/utils/field.utils.d.ts +6 -0
  197. package/dist/utils/field.utils.js +50 -8
  198. package/dist/utils/field.utils.js.map +1 -1
  199. package/dist/utils/field.utils.spec.d.ts +1 -0
  200. package/dist/utils/field.utils.spec.js +38 -0
  201. package/dist/utils/field.utils.spec.js.map +1 -0
  202. package/dist/utils/file.utils.d.ts +23 -1
  203. package/dist/utils/file.utils.js +37 -1
  204. package/dist/utils/file.utils.js.map +1 -1
  205. package/dist/utils/naming.utils.js +5 -4
  206. package/dist/utils/naming.utils.js.map +1 -1
  207. package/dist/utils/static-recipe.utils.d.ts +4 -0
  208. package/dist/utils/static-recipe.utils.js +148 -0
  209. package/dist/utils/static-recipe.utils.js.map +1 -0
  210. package/package.json +12 -5
@@ -0,0 +1,296 @@
1
+ import { hostname } from 'node:os';
2
+ import { randomUUID } from 'node:crypto';
3
+ import {
4
+ Inject,
5
+ Injectable,
6
+ Logger,
7
+ OnApplicationBootstrap,
8
+ OnApplicationShutdown,
9
+ } from '@nestjs/common';
10
+ import { ExternalProjectionStore } from './external-projection-store';
11
+ import {
12
+ ClaimedExternalProjection,
13
+ ExternalProjectionDispositionError,
14
+ ExternalProjectionExecutionResult,
15
+ ExternalProjectionFailureDisposition,
16
+ ExternalProjectionHandler,
17
+ ExternalProjectionWorkerOptions,
18
+ } from './external-projection-worker.types';
19
+
20
+ export const EXTERNAL_PROJECTION_HANDLER = Symbol('EXTERNAL_PROJECTION_HANDLER');
21
+ export const EXTERNAL_PROJECTION_WORKER_OPTIONS = Symbol('EXTERNAL_PROJECTION_WORKER_OPTIONS');
22
+ const LEASE_LOST = Symbol('LEASE_LOST');
23
+
24
+ function boundedInteger(value: number, name: string, minimum: number, maximum: number): number {
25
+ if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
26
+ throw new Error(`${name} must be an integer between ${minimum} and ${maximum}`);
27
+ }
28
+ return value;
29
+ }
30
+
31
+ function projectionName(value: string): string {
32
+ const normalized = value.trim();
33
+ if (
34
+ normalized.length < 1 ||
35
+ normalized.length > 120 ||
36
+ !/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(normalized)
37
+ ) {
38
+ throw new Error('projectionName is invalid');
39
+ }
40
+ return normalized;
41
+ }
42
+
43
+ export function validateExternalProjectionWorkerOptions(
44
+ options: ExternalProjectionWorkerOptions,
45
+ ): Readonly<ExternalProjectionWorkerOptions> {
46
+ if (typeof options.enabled !== 'boolean') throw new Error('enabled must be a boolean');
47
+ const validated = {
48
+ projectionName: projectionName(options.projectionName),
49
+ enabled: options.enabled,
50
+ pollIntervalMs: boundedInteger(options.pollIntervalMs, 'pollIntervalMs', 50, 3_600_000),
51
+ batchSize: boundedInteger(options.batchSize, 'batchSize', 1, 1_000),
52
+ leaseSeconds: boundedInteger(options.leaseSeconds, 'leaseSeconds', 3, 3_600),
53
+ maxAttempts: boundedInteger(options.maxAttempts, 'maxAttempts', 1, 100),
54
+ retryBaseMs: boundedInteger(options.retryBaseMs, 'retryBaseMs', 100, 86_400_000),
55
+ retryMaxMs: boundedInteger(options.retryMaxMs, 'retryMaxMs', 100, 604_800_000),
56
+ };
57
+ if (validated.retryMaxMs < validated.retryBaseMs) {
58
+ throw new Error('retryMaxMs must be greater than or equal to retryBaseMs');
59
+ }
60
+ return Object.freeze(validated);
61
+ }
62
+
63
+ function validErrorCode(value: string): boolean {
64
+ return /^[A-Z][A-Z0-9_]{0,159}$/.test(value);
65
+ }
66
+
67
+ function normalizeExecutionResult(value: unknown): ExternalProjectionExecutionResult {
68
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
69
+ return { disposition: 'blocked', errorCode: 'INVALID_HANDLER_RESULT' };
70
+ }
71
+ const record = value as Record<string, unknown>;
72
+ if (record['disposition'] === 'applied') {
73
+ const resultReference = record['resultReference'];
74
+ if (resultReference !== undefined && typeof resultReference !== 'string') {
75
+ return { disposition: 'blocked', errorCode: 'INVALID_HANDLER_RESULT' };
76
+ }
77
+ const normalizedReference = resultReference?.trim();
78
+ if (
79
+ resultReference !== undefined &&
80
+ (!normalizedReference ||
81
+ normalizedReference.length > 500 ||
82
+ /[\u0000-\u001f\u007f]/.test(normalizedReference))
83
+ ) {
84
+ return { disposition: 'blocked', errorCode: 'INVALID_HANDLER_RESULT_REFERENCE' };
85
+ }
86
+ return {
87
+ disposition: 'applied',
88
+ ...(normalizedReference === undefined ? {} : { resultReference: normalizedReference }),
89
+ };
90
+ }
91
+ const disposition = record['disposition'];
92
+ if (
93
+ typeof disposition !== 'string' ||
94
+ !['retry_wait', 'uncertain', 'blocked'].includes(disposition) ||
95
+ typeof record['errorCode'] !== 'string' ||
96
+ (record['retryAfterMs'] !== undefined && typeof record['retryAfterMs'] !== 'number')
97
+ ) {
98
+ return { disposition: 'blocked', errorCode: 'INVALID_HANDLER_RESULT' };
99
+ }
100
+ return {
101
+ disposition: disposition as ExternalProjectionFailureDisposition,
102
+ errorCode: record['errorCode'],
103
+ ...(record['retryAfterMs'] === undefined
104
+ ? {}
105
+ : { retryAfterMs: record['retryAfterMs'] as number }),
106
+ };
107
+ }
108
+
109
+ @Injectable()
110
+ export class ExternalProjectionWorker implements OnApplicationBootstrap, OnApplicationShutdown {
111
+ private readonly logger = new Logger(ExternalProjectionWorker.name);
112
+ private readonly workerId = `${hostname()}:${process.pid}:${randomUUID()}`;
113
+ private readonly options: Readonly<ExternalProjectionWorkerOptions>;
114
+ private timer?: NodeJS.Timeout;
115
+ private activeRun?: Promise<number>;
116
+ private stopping = false;
117
+
118
+ constructor(
119
+ private readonly store: ExternalProjectionStore,
120
+ @Inject(EXTERNAL_PROJECTION_HANDLER) private readonly handler: ExternalProjectionHandler,
121
+ @Inject(EXTERNAL_PROJECTION_WORKER_OPTIONS) options: ExternalProjectionWorkerOptions,
122
+ ) {
123
+ this.options = validateExternalProjectionWorkerOptions(options);
124
+ }
125
+
126
+ onApplicationBootstrap(): void {
127
+ if (!this.options.enabled || this.stopping) return;
128
+ this.scheduleRun();
129
+ this.timer = setInterval(() => this.scheduleRun(), this.options.pollIntervalMs);
130
+ this.timer.unref();
131
+ }
132
+
133
+ async onApplicationShutdown(): Promise<void> {
134
+ this.stopping = true;
135
+ if (this.timer) clearInterval(this.timer);
136
+ await this.activeRun;
137
+ }
138
+
139
+ runOnce(): Promise<number> {
140
+ if (this.stopping) return Promise.resolve(0);
141
+ this.activeRun ??= this.runBatch().finally(() => {
142
+ this.activeRun = undefined;
143
+ });
144
+ return this.activeRun;
145
+ }
146
+
147
+ private async runBatch(): Promise<number> {
148
+ let processed = 0;
149
+ while (!this.stopping && processed < this.options.batchSize) {
150
+ const operation = await this.store.claimNext(
151
+ this.options.projectionName,
152
+ this.workerId,
153
+ this.options.leaseSeconds,
154
+ this.options.maxAttempts,
155
+ );
156
+ if (!operation) break;
157
+ processed += 1;
158
+ await this.process(operation);
159
+ }
160
+ return processed;
161
+ }
162
+
163
+ private async process(operation: ClaimedExternalProjection): Promise<void> {
164
+ let result: ExternalProjectionExecutionResult;
165
+ try {
166
+ const execution = await this.executeWithLeaseRenewal(operation);
167
+ if (execution === LEASE_LOST) return;
168
+ result = normalizeExecutionResult(execution);
169
+ } catch (error) {
170
+ if (error instanceof ExternalProjectionDispositionError) {
171
+ result = {
172
+ disposition: error.disposition,
173
+ errorCode: error.code,
174
+ retryAfterMs: error.retryAfterMs,
175
+ };
176
+ } else {
177
+ result = { disposition: 'uncertain', errorCode: 'UNEXPECTED_EXECUTION_FAILURE' };
178
+ }
179
+ }
180
+
181
+ if (result.disposition === 'applied') {
182
+ if (!(await this.store.markApplied(operation, result.resultReference))) {
183
+ this.logLeaseLost(operation.id);
184
+ }
185
+ return;
186
+ }
187
+
188
+ const normalized = this.normalizeFailure(
189
+ result,
190
+ operation.attemptCount,
191
+ operation.reconciliationRequired,
192
+ );
193
+ const nextAttemptAt =
194
+ normalized.disposition === 'blocked'
195
+ ? undefined
196
+ : new Date(Date.now() + normalized.retryAfterMs);
197
+ if (
198
+ !(await this.store.markFailure(operation, {
199
+ disposition: normalized.disposition,
200
+ errorCode: normalized.errorCode,
201
+ nextAttemptAt,
202
+ }))
203
+ ) {
204
+ this.logLeaseLost(operation.id);
205
+ }
206
+ }
207
+
208
+ private async executeWithLeaseRenewal(operation: ClaimedExternalProjection): Promise<unknown> {
209
+ let leaseValid = true;
210
+ let stopped = false;
211
+ let renewal: Promise<void> = Promise.resolve();
212
+ const renew = (): void => {
213
+ renewal = renewal.then(async () => {
214
+ if (stopped || !leaseValid) return;
215
+ try {
216
+ leaseValid = await this.store.renewLease(operation, this.options.leaseSeconds);
217
+ } catch {
218
+ leaseValid = false;
219
+ }
220
+ });
221
+ };
222
+ const interval = setInterval(
223
+ renew,
224
+ Math.max(1_000, Math.floor((this.options.leaseSeconds * 1_000) / 3)),
225
+ );
226
+ interval.unref();
227
+ try {
228
+ const result = operation.reconciliationRequired
229
+ ? await this.handler.reconcile(operation)
230
+ : await this.handler.execute(operation);
231
+ stopped = true;
232
+ clearInterval(interval);
233
+ await renewal;
234
+ if (!leaseValid || !(await this.store.renewLease(operation, this.options.leaseSeconds))) {
235
+ this.logLeaseLost(operation.id);
236
+ return LEASE_LOST;
237
+ }
238
+ return result;
239
+ } finally {
240
+ stopped = true;
241
+ clearInterval(interval);
242
+ await renewal;
243
+ }
244
+ }
245
+
246
+ private normalizeFailure(
247
+ result: Exclude<ExternalProjectionExecutionResult, { disposition: 'applied' }>,
248
+ attemptCount: number,
249
+ reconciliationRequired: boolean,
250
+ ): {
251
+ disposition: ExternalProjectionFailureDisposition;
252
+ errorCode: string;
253
+ retryAfterMs: number;
254
+ } {
255
+ if (!validErrorCode(result.errorCode)) {
256
+ return { disposition: 'blocked', errorCode: 'INVALID_HANDLER_ERROR_CODE', retryAfterMs: 0 };
257
+ }
258
+ if (result.disposition === 'blocked' || attemptCount >= this.options.maxAttempts) {
259
+ return { disposition: 'blocked', errorCode: result.errorCode, retryAfterMs: 0 };
260
+ }
261
+ const disposition =
262
+ reconciliationRequired && result.disposition === 'retry_wait'
263
+ ? 'uncertain'
264
+ : result.disposition;
265
+ const fallback = Math.min(
266
+ this.options.retryMaxMs,
267
+ this.options.retryBaseMs * 2 ** Math.max(0, Math.min(20, attemptCount - 1)),
268
+ );
269
+ const requested = result.retryAfterMs ?? fallback;
270
+ if (
271
+ !Number.isSafeInteger(requested) ||
272
+ requested < 100 ||
273
+ requested > this.options.retryMaxMs
274
+ ) {
275
+ return { disposition: 'blocked', errorCode: 'INVALID_HANDLER_RETRY_DELAY', retryAfterMs: 0 };
276
+ }
277
+ return {
278
+ disposition,
279
+ errorCode: result.errorCode,
280
+ retryAfterMs: requested,
281
+ };
282
+ }
283
+
284
+ private scheduleRun(): void {
285
+ if (this.stopping) return;
286
+ void this.runOnce().catch(() => {
287
+ this.logger.error('External projection poll failed');
288
+ });
289
+ }
290
+
291
+ private logLeaseLost(operationId: string): void {
292
+ this.logger.warn(
293
+ `External projection ${operationId} was not completed because its lease changed`,
294
+ );
295
+ }
296
+ }
@@ -0,0 +1,50 @@
1
+ # Kafka transactional consumer
2
+
3
+ Generated by `ddd recipe kafka-consumer`. Independent of Relay/Pulsar:
4
+ this recipe does not replace an existing outbox or enable a live consumer.
5
+
6
+ ## Integrate
7
+
8
+ 1. Install `kafkajs`, `typeorm` and `@nestjs/common` in the consuming application.
9
+ 2. Review/register the migration in the existing runner before enabling consumption.
10
+ 3. Generate a business handler with `ddd generate service`. Implement
11
+ `KafkaTransactionalHandler`, declare/export it in its owning Nest module,
12
+ then use `KafkaConsumerModule.register(options, Handler, [HandlerOwnerModule])`.
13
+ Do not redeclare the handler provider in the consumer module.
14
+ 4. Supply KafkaConfig from validated application configuration, with TLS/SASL
15
+ where required. Never embed credentials. Set explicit topics, a stable group
16
+ per independently maintained projection and a deliberate `fromBeginning` value.
17
+ 5. Enable Nest shutdown hooks. Keep database handlers within the session timeout.
18
+
19
+ ## Debezium contract
20
+
21
+ Commit domain state and outbox together. Debezium's Outbox Event Router emits
22
+ the durable event ID as an `id` header (`table.field.event.id=id`). Preserve
23
+ aggregate ID as the record key. Prove envelope/header mapping against actual
24
+ producer output; do not assume registry declarations determine serialization.
25
+
26
+ The handler owns payload decoding, schema validation, tenant binding and source
27
+ revision enforcement. Kafka facts never grant access to protected data. Keep
28
+ clinical documents in their owner, not in a cross-service projection.
29
+
30
+ ## Guarantees and limits
31
+
32
+ Receipt and handler changes share a database transaction; acknowledgement follows
33
+ commit. Duplicate IDs with unchanged payload skip the handler; changed content
34
+ fails closed. Keep receipts for the entire replay horizon. Offsets use BigInt.
35
+
36
+ This is NOT exactly-once external delivery. Do not call HTTP/payment/email from
37
+ the transaction. Append a domain outbox intent instead, with business replay
38
+ fences. Do not use this receipt as a cached authorization decision.
39
+
40
+ Malformed/poison messages stop progress instead of disappearing. Before enabling
41
+ production, wire crash/lag alerts and the application's owned quarantine/replay
42
+ policy. Never acknowledge rejected events without durably recording their reason.
43
+ Broker connection retries are bounded; handler recovery needs operator visibility.
44
+
45
+ Prove real PostgreSQL/Kafka migration reversal, concurrent duplicate, rollback,
46
+ commit-before-ack crash, rebalance, out-of-order revision, poison and shutdown.
47
+ Define initial backfill; broker retention is not a backup. A healthy HTTP API
48
+ does not prove healthy consumption.
49
+
50
+ Reference: https://kafka.js.org/docs/consuming
@@ -0,0 +1,135 @@
1
+ import { createHash } from 'node:crypto';
2
+ import {
3
+ DynamicModule, Inject, Injectable, Module, ModuleMetadata,
4
+ OnApplicationBootstrap, OnApplicationShutdown, Type,
5
+ } from '@nestjs/common';
6
+ import { Consumer, EachMessagePayload, Kafka, KafkaConfig } from 'kafkajs';
7
+ import { DataSource, EntityManager } from 'typeorm';
8
+
9
+ export const KAFKA_CONSUMER_OPTIONS = Symbol('KAFKA_CONSUMER_OPTIONS');
10
+ export const KAFKA_TRANSACTIONAL_HANDLER = Symbol('KAFKA_TRANSACTIONAL_HANDLER');
11
+
12
+ export interface KafkaConsumerOptions {
13
+ kafka: KafkaConfig;
14
+ groupId: string;
15
+ topics: string[];
16
+ fromBeginning: boolean;
17
+ }
18
+
19
+ export interface KafkaTransactionalHandler {
20
+ /** Validate contracts/revisions; use only this transaction. External effects belong in an outbox. */
21
+ handle(message: EachMessagePayload, manager: EntityManager): Promise<void>;
22
+ }
23
+
24
+ @Injectable()
25
+ export class KafkaMessageProcessor {
26
+ constructor(
27
+ private readonly dataSource: DataSource,
28
+ @Inject(KAFKA_CONSUMER_OPTIONS) private readonly options: KafkaConsumerOptions,
29
+ @Inject(KAFKA_TRANSACTIONAL_HANDLER) private readonly handler: KafkaTransactionalHandler,
30
+ ) {}
31
+
32
+ async process(input: EachMessagePayload): Promise<void> {
33
+ const { message, topic } = input;
34
+ const rawId = message.headers?.id;
35
+ const id = Array.isArray(rawId) ? undefined : rawId?.toString();
36
+ if (!id || !/^[A-Za-z0-9._:-]{1,200}$/.test(id)) {
37
+ throw new Error('Kafka event requires a single stable outbox id header');
38
+ }
39
+ if (!message.key?.length || !message.value?.length || message.value.length > 1048576) {
40
+ throw new Error('Kafka event requires an aggregate key and bounded non-empty payload');
41
+ }
42
+ if (!this.options.topics.includes(topic)) throw new Error('Unexpected Kafka topic');
43
+ const hash = createHash('sha256')
44
+ .update(JSON.stringify([topic, message.key.toString('base64'), message.value.toString('base64')]))
45
+ .digest('hex');
46
+ await this.dataSource.transaction(async (manager) => {
47
+ const inserted = await manager.query(
48
+ 'INSERT INTO kafka_consumer_receipts (consumer_id, event_id, payload_hash) ' +
49
+ 'VALUES ($1, $2, $3) ON CONFLICT (consumer_id, event_id) DO NOTHING RETURNING event_id',
50
+ [this.options.groupId, id, hash],
51
+ ) as Array<{ event_id: string }>;
52
+ if (inserted.length === 0) {
53
+ const existing = await manager.query(
54
+ 'SELECT payload_hash FROM kafka_consumer_receipts WHERE consumer_id = $1 AND event_id = $2',
55
+ [this.options.groupId, id],
56
+ ) as Array<{ payload_hash: string }>;
57
+ if (existing.length !== 1 || existing[0]?.payload_hash !== hash) {
58
+ throw new Error('Kafka event identity conflict');
59
+ }
60
+ return;
61
+ }
62
+ await this.handler.handle(input, manager);
63
+ });
64
+ }
65
+ }
66
+
67
+ @Injectable()
68
+ export class KafkaConsumerLifecycle implements OnApplicationBootstrap, OnApplicationShutdown {
69
+ private consumer?: Consumer;
70
+
71
+ constructor(
72
+ @Inject(KAFKA_CONSUMER_OPTIONS) private readonly options: KafkaConsumerOptions,
73
+ private readonly processor: KafkaMessageProcessor,
74
+ ) {}
75
+
76
+ async onApplicationBootstrap(): Promise<void> {
77
+ if (!/^[A-Za-z0-9._-]{1,200}$/.test(this.options.groupId) ||
78
+ this.options.topics.length === 0 ||
79
+ this.options.topics.some(topic => !/^[A-Za-z0-9._-]{1,249}$/.test(topic)) ||
80
+ typeof this.options.fromBeginning !== 'boolean') {
81
+ throw new Error('Kafka consumer requires explicit group, topics and replay policy');
82
+ }
83
+ const consumer = new Kafka(this.options.kafka).consumer({
84
+ groupId: this.options.groupId,
85
+ allowAutoTopicCreation: false,
86
+ retry: { retries: 5 },
87
+ });
88
+ this.consumer = consumer;
89
+ try {
90
+ await consumer.connect();
91
+ await consumer.subscribe({ topics: this.options.topics, fromBeginning: this.options.fromBeginning });
92
+ await consumer.run({
93
+ autoCommit: false,
94
+ partitionsConsumedConcurrently: 1,
95
+ eachMessage: async (input) => {
96
+ await this.processor.process(input);
97
+ await input.heartbeat();
98
+ await consumer.commitOffsets([{
99
+ topic: input.topic,
100
+ partition: input.partition,
101
+ offset: (BigInt(input.message.offset) + 1n).toString(),
102
+ }]);
103
+ },
104
+ });
105
+ } catch (error) {
106
+ await consumer.disconnect();
107
+ throw error;
108
+ }
109
+ }
110
+
111
+ async onApplicationShutdown(): Promise<void> {
112
+ await this.consumer?.stop();
113
+ await this.consumer?.disconnect();
114
+ }
115
+ }
116
+
117
+ @Module({})
118
+ export class KafkaConsumerModule {
119
+ static register(
120
+ options: KafkaConsumerOptions,
121
+ handler: Type<KafkaTransactionalHandler>,
122
+ imports: ModuleMetadata['imports'],
123
+ ): DynamicModule {
124
+ return {
125
+ module: KafkaConsumerModule,
126
+ imports,
127
+ providers: [
128
+ { provide: KAFKA_CONSUMER_OPTIONS, useValue: options },
129
+ { provide: KAFKA_TRANSACTIONAL_HANDLER, useExisting: handler },
130
+ KafkaMessageProcessor,
131
+ KafkaConsumerLifecycle,
132
+ ],
133
+ };
134
+ }
135
+ }
@@ -0,0 +1,17 @@
1
+ import { MigrationInterface, QueryRunner } from 'typeorm';
2
+
3
+ export class KafkaConsumerReceipts1788600000000 implements MigrationInterface {
4
+ async up(queryRunner: QueryRunner): Promise<void> {
5
+ await queryRunner.query(`CREATE TABLE kafka_consumer_receipts (
6
+ consumer_id varchar(200) NOT NULL,
7
+ event_id varchar(200) NOT NULL,
8
+ payload_hash char(64) NOT NULL,
9
+ processed_at timestamptz NOT NULL DEFAULT now(),
10
+ PRIMARY KEY (consumer_id, event_id)
11
+ )`);
12
+ }
13
+
14
+ async down(queryRunner: QueryRunner): Promise<void> {
15
+ await queryRunner.query('DROP TABLE kafka_consumer_receipts');
16
+ }
17
+ }
@@ -0,0 +1,40 @@
1
+ # OAuth2 client credentials
2
+
3
+ Generated by `ddd recipe oauth2-client-credentials --dry-run`, then the same
4
+ command without `--dry-run`. This adds a dependency-free grant client, Nest
5
+ sync/async module, tests, and the exact bounded JSON HTTP primitive used by the
6
+ introspection recipe. Both recipes refuse existing changed output; do not delete
7
+ adapted files to force an upgrade. No AppModule registration or permission grant.
8
+
9
+ `OAuth2ClientCredentialsClient` takes a fixed HTTPS `tokenUrl`, `clientId`,
10
+ `clientSecret`, optional `scopes`, and bounded timeout/response/token limits.
11
+ It uses RFC6749 client-secret Basic authentication (form-encoded components),
12
+ `grant_type=client_credentials`, and requires Bearer type plus explicit bounded
13
+ positive `expires_in`. Decimal strings are rejected unless the consumer explicitly
14
+ sets `allowStringExpiresIn: true` for a documented provider contract.
15
+
16
+ `getAccessToken()` caches until a conservative expiry calculated from request
17
+ start; `renewalMarginMs` is bounded and capped at ten percent of token lifetime.
18
+ Concurrent callers share one grant acquisition. Failure clears that acquisition;
19
+ no stale token, background grant, refresh-token path or implicit retry exists.
20
+ `invalidate(rejectedToken)` evicts only that current token. It does not resend a
21
+ provider operation; definite-rejection classification belongs to the provider.
22
+
23
+ All credential/cache storage uses ECMAScript private fields. Errors retain only
24
+ safe reason and optional HTTP status, without raw responses, causes or headers.
25
+ Configuration options and returned tokens remain secret-bearing: never log them.
26
+
27
+ The bounded helper supports `get`, `postForm`, and `postJson`. JSON is snapshotted
28
+ before awaiting, supports bounded plain values, and refuses cycles, accessors,
29
+ non-finite numbers, unsupported prototypes, excessive depth/nodes/UTF8 bytes.
30
+ Its trusted headers are immutable and hidden from object serialization. Construct
31
+ a fresh helper with the current Bearer token after acquisition rather than mutate
32
+ the original header object. HTTP has no redirects, no implicit retries, bounded
33
+ response/deadline and sanitized errors. Providers own response schema/acceptance.
34
+
35
+ This is transport source infrastructure. It does not prove provider credentials,
36
+ funding, destination/sender capability, delivered messages, authorization or an
37
+ exactly-once external side effect. Unknown provider write results require owning
38
+ reconciliation and cannot be retried by this helper.
39
+
40
+ Protocol: [RFC6749 client credentials](https://www.rfc-editor.org/rfc/rfc6749#section-4.4).