nestjs-ddd-cli 3.2.2 → 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 (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,354 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { Inject, Injectable } from '@nestjs/common';
3
+ import { DataSource } from 'typeorm';
4
+ import { canonicalizeExternalProjectionJson } from './canonical-json';
5
+ import {
6
+ ClaimedExternalProjection,
7
+ ExternalProjectionFailureDisposition,
8
+ ExternalProjectionJsonValue,
9
+ } from './external-projection-worker.types';
10
+
11
+ type DatabaseDate = Date | string | null;
12
+
13
+ interface ClaimedOperationRow {
14
+ id: string;
15
+ projection_name: string;
16
+ target_key: string;
17
+ operation_kind: string;
18
+ idempotency_key: string;
19
+ payload: unknown;
20
+ payload_hash: string;
21
+ status: 'processing';
22
+ attempt_count: number | string;
23
+ result_reference: string | null;
24
+ next_attempt_at: DatabaseDate;
25
+ last_attempt_at: DatabaseDate;
26
+ applied_at: DatabaseDate;
27
+ last_error_code: string | null;
28
+ lease_owner: string;
29
+ lease_token: string;
30
+ lease_expires_at: Date | string;
31
+ created_at: Date | string;
32
+ updated_at: Date | string;
33
+ previous_status: 'pending' | 'processing' | 'retry_wait' | 'uncertain';
34
+ previous_error_code: string | null;
35
+ claim_reason: 'fresh' | 'retry' | 'uncertain_recovery' | 'expired_lease_recovery';
36
+ }
37
+
38
+ export interface ExternalProjectionFailureCompletion {
39
+ disposition: ExternalProjectionFailureDisposition;
40
+ errorCode: string;
41
+ nextAttemptAt?: Date;
42
+ }
43
+
44
+ function rowsFromQuery<T>(result: unknown): T[] {
45
+ if (!Array.isArray(result)) return [];
46
+ return Array.isArray(result[0]) ? (result[0] as T[]) : (result as T[]);
47
+ }
48
+
49
+ function optionalDate(value: Date | string | null): Date | undefined {
50
+ return value === null ? undefined : new Date(value);
51
+ }
52
+
53
+ function claimedFromRow(row: ClaimedOperationRow): ClaimedExternalProjection {
54
+ const payload: ExternalProjectionJsonValue = canonicalizeExternalProjectionJson(
55
+ row.payload,
56
+ ).value;
57
+ const operation = {
58
+ id: row.id,
59
+ projectionName: row.projection_name,
60
+ targetKey: row.target_key,
61
+ operationKind: row.operation_kind,
62
+ idempotencyKey: row.idempotency_key,
63
+ payload,
64
+ payloadHash: row.payload_hash,
65
+ status: 'processing' as const,
66
+ attemptCount: Number(row.attempt_count),
67
+ resultReference: row.result_reference ?? undefined,
68
+ nextAttemptAt: optionalDate(row.next_attempt_at),
69
+ lastAttemptAt: optionalDate(row.last_attempt_at),
70
+ appliedAt: optionalDate(row.applied_at),
71
+ lastErrorCode: row.last_error_code ?? undefined,
72
+ leaseOwner: row.lease_owner,
73
+ leaseToken: row.lease_token,
74
+ leaseExpiresAt: new Date(row.lease_expires_at),
75
+ createdAt: new Date(row.created_at),
76
+ updatedAt: new Date(row.updated_at),
77
+ };
78
+ if (row.claim_reason === 'fresh' && row.previous_status === 'pending') {
79
+ return {
80
+ ...operation,
81
+ claimReason: 'fresh',
82
+ previousStatus: 'pending',
83
+ reconciliationRequired: false,
84
+ };
85
+ }
86
+ if (
87
+ row.claim_reason === 'retry' &&
88
+ row.previous_status === 'retry_wait' &&
89
+ row.previous_error_code
90
+ ) {
91
+ return {
92
+ ...operation,
93
+ claimReason: 'retry',
94
+ previousStatus: 'retry_wait',
95
+ previousErrorCode: row.previous_error_code,
96
+ reconciliationRequired: false,
97
+ };
98
+ }
99
+ if (
100
+ row.claim_reason === 'uncertain_recovery' &&
101
+ row.previous_status === 'uncertain' &&
102
+ row.previous_error_code
103
+ ) {
104
+ return {
105
+ ...operation,
106
+ claimReason: 'uncertain_recovery',
107
+ previousStatus: 'uncertain',
108
+ previousErrorCode: row.previous_error_code,
109
+ reconciliationRequired: true,
110
+ };
111
+ }
112
+ if (row.claim_reason === 'expired_lease_recovery' && row.previous_status === 'processing') {
113
+ return {
114
+ ...operation,
115
+ claimReason: 'expired_lease_recovery',
116
+ previousStatus: 'processing',
117
+ previousErrorCode: row.previous_error_code ?? undefined,
118
+ reconciliationRequired: true,
119
+ };
120
+ }
121
+ throw new Error('External projection claim provenance is inconsistent');
122
+ }
123
+
124
+ function validProjectionName(value: string): string {
125
+ const normalized = value.trim();
126
+ if (
127
+ normalized.length < 1 ||
128
+ normalized.length > 120 ||
129
+ !/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(normalized)
130
+ ) {
131
+ throw new Error('External projection name is invalid');
132
+ }
133
+ return normalized;
134
+ }
135
+
136
+ function validWorkerId(value: string): string {
137
+ const normalized = value.trim();
138
+ if (
139
+ normalized.length < 1 ||
140
+ normalized.length > 200 ||
141
+ /[\u0000-\u001f\u007f]/.test(normalized)
142
+ ) {
143
+ throw new Error('External projection worker id is invalid');
144
+ }
145
+ return normalized;
146
+ }
147
+
148
+ function validLeaseSeconds(value: number): number {
149
+ if (!Number.isSafeInteger(value) || value < 3 || value > 3600) {
150
+ throw new Error('External projection lease must be between 3 and 3600 seconds');
151
+ }
152
+ return value;
153
+ }
154
+
155
+ function validMaxAttempts(value: number): number {
156
+ if (!Number.isSafeInteger(value) || value < 1 || value > 100) {
157
+ throw new Error('External projection max attempts must be between 1 and 100');
158
+ }
159
+ return value;
160
+ }
161
+
162
+ function validErrorCode(value: string): string {
163
+ if (!/^[A-Z][A-Z0-9_]{0,159}$/.test(value)) {
164
+ throw new Error('External projection error code is invalid');
165
+ }
166
+ return value;
167
+ }
168
+
169
+ @Injectable()
170
+ export class ExternalProjectionStore {
171
+ constructor(@Inject(DataSource) private readonly dataSource: DataSource) {}
172
+
173
+ async claimNext(
174
+ projectionName: string,
175
+ workerId: string,
176
+ leaseSeconds: number,
177
+ maxAttempts: number,
178
+ ): Promise<ClaimedExternalProjection | null> {
179
+ const projection = validProjectionName(projectionName);
180
+ const leaseOwner = validWorkerId(workerId);
181
+ const boundedLease = validLeaseSeconds(leaseSeconds);
182
+ const boundedAttempts = validMaxAttempts(maxAttempts);
183
+ const leaseToken = randomUUID();
184
+ return this.dataSource.transaction(async (manager) => {
185
+ await manager.query(
186
+ `UPDATE external_projection_operations
187
+ SET status = 'blocked',
188
+ lease_owner = NULL,
189
+ lease_token = NULL,
190
+ lease_expires_at = NULL,
191
+ next_attempt_at = NULL,
192
+ last_error_code = 'MAX_ATTEMPTS_EXHAUSTED',
193
+ updated_at = now()
194
+ WHERE projection_name = $1
195
+ AND attempt_count >= $2
196
+ AND (
197
+ status IN ('pending', 'retry_wait', 'uncertain')
198
+ OR (status = 'processing' AND lease_expires_at <= now())
199
+ )
200
+ AND (next_attempt_at IS NULL OR next_attempt_at <= now())
201
+ AND (lease_expires_at IS NULL OR lease_expires_at <= now())`,
202
+ [projection, boundedAttempts],
203
+ );
204
+ const rows = rowsFromQuery<ClaimedOperationRow>(
205
+ await manager.query(
206
+ `WITH candidate AS (
207
+ SELECT operation.id,
208
+ operation.status AS previous_status,
209
+ operation.last_error_code AS previous_error_code,
210
+ CASE operation.status
211
+ WHEN 'pending' THEN 'fresh'
212
+ WHEN 'retry_wait' THEN 'retry'
213
+ WHEN 'uncertain' THEN 'uncertain_recovery'
214
+ WHEN 'processing' THEN 'expired_lease_recovery'
215
+ END AS claim_reason
216
+ FROM external_projection_operations AS operation
217
+ WHERE (
218
+ operation.status IN ('pending', 'retry_wait', 'uncertain')
219
+ OR (operation.status = 'processing' AND operation.lease_expires_at <= now())
220
+ )
221
+ AND operation.projection_name = $1
222
+ AND operation.attempt_count < $5
223
+ AND (operation.next_attempt_at IS NULL OR operation.next_attempt_at <= now())
224
+ AND (operation.lease_expires_at IS NULL OR operation.lease_expires_at <= now())
225
+ AND NOT EXISTS (
226
+ SELECT 1
227
+ FROM external_projection_operations AS predecessor
228
+ WHERE predecessor.projection_name = operation.projection_name
229
+ AND predecessor.target_key = operation.target_key
230
+ AND predecessor.enqueue_sequence < operation.enqueue_sequence
231
+ AND predecessor.status <> 'applied'
232
+ )
233
+ ORDER BY COALESCE(operation.next_attempt_at, operation.created_at),
234
+ operation.enqueue_sequence
235
+ FOR UPDATE OF operation SKIP LOCKED
236
+ LIMIT 1
237
+ )
238
+ UPDATE external_projection_operations AS operation
239
+ SET status = 'processing',
240
+ attempt_count = operation.attempt_count + 1,
241
+ lease_owner = $2,
242
+ lease_token = $3,
243
+ lease_expires_at = now() + make_interval(secs => $4::int),
244
+ next_attempt_at = NULL,
245
+ last_attempt_at = now(),
246
+ last_error_code = NULL,
247
+ updated_at = now()
248
+ FROM candidate
249
+ WHERE operation.id = candidate.id
250
+ RETURNING operation.*,
251
+ candidate.previous_status,
252
+ candidate.previous_error_code,
253
+ candidate.claim_reason`,
254
+ [projection, leaseOwner, leaseToken, boundedLease, boundedAttempts],
255
+ ),
256
+ );
257
+ return rows[0] ? claimedFromRow(rows[0]) : null;
258
+ });
259
+ }
260
+
261
+ async renewLease(operation: ClaimedExternalProjection, leaseSeconds: number): Promise<boolean> {
262
+ const boundedLease = validLeaseSeconds(leaseSeconds);
263
+ const rows = rowsFromQuery<{ id: string }>(
264
+ await this.dataSource.query(
265
+ `UPDATE external_projection_operations
266
+ SET lease_expires_at = now() + make_interval(secs => $4::int), updated_at = now()
267
+ WHERE id = $1
268
+ AND status = 'processing'
269
+ AND lease_owner = $2
270
+ AND lease_token = $3
271
+ AND lease_expires_at > now()
272
+ RETURNING id`,
273
+ [operation.id, operation.leaseOwner, operation.leaseToken, boundedLease],
274
+ ),
275
+ );
276
+ return rows.length === 1;
277
+ }
278
+
279
+ async markApplied(
280
+ operation: ClaimedExternalProjection,
281
+ resultReference?: string,
282
+ ): Promise<boolean> {
283
+ const reference = resultReference?.trim();
284
+ if (
285
+ resultReference !== undefined &&
286
+ (!reference || reference.length > 500 || /[\u0000-\u001f\u007f]/.test(reference))
287
+ ) {
288
+ throw new TypeError('External projection result reference is invalid');
289
+ }
290
+ const rows = rowsFromQuery<{ id: string }>(
291
+ await this.dataSource.query(
292
+ `UPDATE external_projection_operations
293
+ SET status = 'applied',
294
+ result_reference = $4,
295
+ applied_at = now(),
296
+ lease_owner = NULL,
297
+ lease_token = NULL,
298
+ lease_expires_at = NULL,
299
+ next_attempt_at = NULL,
300
+ last_error_code = NULL,
301
+ updated_at = now()
302
+ WHERE id = $1
303
+ AND status = 'processing'
304
+ AND lease_owner = $2
305
+ AND lease_token = $3
306
+ AND lease_expires_at > now()
307
+ RETURNING id`,
308
+ [operation.id, operation.leaseOwner, operation.leaseToken, reference ?? null],
309
+ ),
310
+ );
311
+ return rows.length === 1;
312
+ }
313
+
314
+ async markFailure(
315
+ operation: ClaimedExternalProjection,
316
+ completion: ExternalProjectionFailureCompletion,
317
+ ): Promise<boolean> {
318
+ const errorCode = validErrorCode(completion.errorCode);
319
+ const retrying = completion.disposition !== 'blocked';
320
+ if (retrying !== completion.nextAttemptAt instanceof Date) {
321
+ throw new TypeError('External projection retry outcomes require one next-attempt time');
322
+ }
323
+ if (completion.nextAttemptAt && !Number.isFinite(completion.nextAttemptAt.getTime())) {
324
+ throw new Error('External projection next-attempt time is invalid');
325
+ }
326
+ const rows = rowsFromQuery<{ id: string }>(
327
+ await this.dataSource.query(
328
+ `UPDATE external_projection_operations
329
+ SET status = $4,
330
+ lease_owner = NULL,
331
+ lease_token = NULL,
332
+ lease_expires_at = NULL,
333
+ next_attempt_at = $5,
334
+ last_error_code = $6,
335
+ updated_at = now()
336
+ WHERE id = $1
337
+ AND status = 'processing'
338
+ AND lease_owner = $2
339
+ AND lease_token = $3
340
+ AND lease_expires_at > now()
341
+ RETURNING id`,
342
+ [
343
+ operation.id,
344
+ operation.leaseOwner,
345
+ operation.leaseToken,
346
+ completion.disposition,
347
+ completion.nextAttemptAt ?? null,
348
+ errorCode,
349
+ ],
350
+ ),
351
+ );
352
+ return rows.length === 1;
353
+ }
354
+ }
@@ -0,0 +1,134 @@
1
+ export type ExternalProjectionJsonValue =
2
+ | null
3
+ | boolean
4
+ | number
5
+ | string
6
+ | ExternalProjectionJsonValue[]
7
+ | { [key: string]: ExternalProjectionJsonValue };
8
+
9
+ export type ExternalProjectionStatus =
10
+ | 'pending'
11
+ | 'processing'
12
+ | 'retry_wait'
13
+ | 'uncertain'
14
+ | 'blocked'
15
+ | 'applied';
16
+
17
+ export interface ExternalProjectionIntent {
18
+ projectionName: string;
19
+ targetKey: string;
20
+ operationKind: string;
21
+ idempotencyKey: string;
22
+ payload: unknown;
23
+ }
24
+
25
+ export interface ExternalProjectionOperation {
26
+ id: string;
27
+ projectionName: string;
28
+ targetKey: string;
29
+ operationKind: string;
30
+ idempotencyKey: string;
31
+ payload: ExternalProjectionJsonValue;
32
+ payloadHash: string;
33
+ status: ExternalProjectionStatus;
34
+ attemptCount: number;
35
+ resultReference?: string;
36
+ nextAttemptAt?: Date;
37
+ lastAttemptAt?: Date;
38
+ appliedAt?: Date;
39
+ lastErrorCode?: string;
40
+ createdAt: Date;
41
+ updatedAt: Date;
42
+ }
43
+
44
+ export interface EnqueuedExternalProjection {
45
+ operation: ExternalProjectionOperation;
46
+ created: boolean;
47
+ }
48
+
49
+ interface ClaimedExternalProjectionBase extends ExternalProjectionOperation {
50
+ status: 'processing';
51
+ leaseOwner: string;
52
+ leaseToken: string;
53
+ leaseExpiresAt: Date;
54
+ }
55
+
56
+ export type ClaimedExternalProjection =
57
+ | (ClaimedExternalProjectionBase & {
58
+ claimReason: 'fresh';
59
+ previousStatus: 'pending';
60
+ previousErrorCode?: never;
61
+ reconciliationRequired: false;
62
+ })
63
+ | (ClaimedExternalProjectionBase & {
64
+ claimReason: 'retry';
65
+ previousStatus: 'retry_wait';
66
+ previousErrorCode: string;
67
+ reconciliationRequired: false;
68
+ })
69
+ | (ClaimedExternalProjectionBase & {
70
+ claimReason: 'uncertain_recovery';
71
+ previousStatus: 'uncertain';
72
+ previousErrorCode: string;
73
+ reconciliationRequired: true;
74
+ })
75
+ | (ClaimedExternalProjectionBase & {
76
+ claimReason: 'expired_lease_recovery';
77
+ previousStatus: 'processing';
78
+ previousErrorCode?: string;
79
+ reconciliationRequired: true;
80
+ });
81
+
82
+ export type FreshExternalProjection = Extract<
83
+ ClaimedExternalProjection,
84
+ { reconciliationRequired: false }
85
+ >;
86
+
87
+ export type RecoveryExternalProjection = Extract<
88
+ ClaimedExternalProjection,
89
+ { reconciliationRequired: true }
90
+ >;
91
+
92
+ export type ExternalProjectionFailureDisposition = 'retry_wait' | 'uncertain' | 'blocked';
93
+
94
+ export type ExternalProjectionExecutionResult =
95
+ | { disposition: 'applied'; resultReference?: string }
96
+ | {
97
+ disposition: ExternalProjectionFailureDisposition;
98
+ errorCode: string;
99
+ retryAfterMs?: number;
100
+ };
101
+
102
+ export interface ExternalProjectionHandler {
103
+ execute(operation: FreshExternalProjection): Promise<ExternalProjectionExecutionResult>;
104
+ reconcile(operation: RecoveryExternalProjection): Promise<ExternalProjectionExecutionResult>;
105
+ }
106
+
107
+ export interface ExternalProjectionWorkerOptions {
108
+ projectionName: string;
109
+ enabled: boolean;
110
+ pollIntervalMs: number;
111
+ batchSize: number;
112
+ leaseSeconds: number;
113
+ maxAttempts: number;
114
+ retryBaseMs: number;
115
+ retryMaxMs: number;
116
+ }
117
+
118
+ export class ExternalProjectionIdempotencyConflictError extends Error {
119
+ constructor() {
120
+ super('External projection idempotency key was reused with different content');
121
+ this.name = 'ExternalProjectionIdempotencyConflictError';
122
+ }
123
+ }
124
+
125
+ export class ExternalProjectionDispositionError extends Error {
126
+ constructor(
127
+ public readonly disposition: ExternalProjectionFailureDisposition,
128
+ public readonly code: string,
129
+ public readonly retryAfterMs?: number,
130
+ ) {
131
+ super(code);
132
+ this.name = 'ExternalProjectionDispositionError';
133
+ }
134
+ }