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.
- package/README.md +118 -9
- package/ddd.schema.json +11 -1
- package/dist/commands/doctor-enhanced.d.ts +1 -0
- package/dist/commands/doctor-enhanced.js +94 -79
- package/dist/commands/doctor-enhanced.js.map +1 -1
- package/dist/commands/doctor.d.ts +9 -0
- package/dist/commands/doctor.js +68 -21
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/generate-all.js +238 -176
- package/dist/commands/generate-all.js.map +1 -1
- package/dist/commands/generate-controller.d.ts +1 -0
- package/dist/commands/generate-controller.js +71 -0
- package/dist/commands/generate-controller.js.map +1 -0
- package/dist/commands/generate-domain-service.d.ts +1 -1
- package/dist/commands/generate-domain-service.js +6 -4
- package/dist/commands/generate-domain-service.js.map +1 -1
- package/dist/commands/generate-dto.d.ts +4 -0
- package/dist/commands/generate-dto.js +162 -0
- package/dist/commands/generate-dto.js.map +1 -0
- package/dist/commands/generate-entity.js +44 -35
- package/dist/commands/generate-entity.js.map +1 -1
- package/dist/commands/generate-event.js +10 -4
- package/dist/commands/generate-event.js.map +1 -1
- package/dist/commands/generate-migration-deployment.d.ts +15 -0
- package/dist/commands/generate-migration-deployment.js +326 -0
- package/dist/commands/generate-migration-deployment.js.map +1 -0
- package/dist/commands/generate-migration-deployment.spec.d.ts +1 -0
- package/dist/commands/generate-migration-deployment.spec.js +337 -0
- package/dist/commands/generate-migration-deployment.spec.js.map +1 -0
- package/dist/commands/generate-module.js +41 -31
- package/dist/commands/generate-module.js.map +1 -1
- package/dist/commands/generate-query.js +23 -4
- package/dist/commands/generate-query.js.map +1 -1
- package/dist/commands/generate-repository.d.ts +1 -1
- package/dist/commands/generate-repository.js +17 -34
- package/dist/commands/generate-repository.js.map +1 -1
- package/dist/commands/generate-service.js +19 -4
- package/dist/commands/generate-service.js.map +1 -1
- package/dist/commands/generate-usecase.js +12 -31
- package/dist/commands/generate-usecase.js.map +1 -1
- package/dist/commands/generate-value-object.js +3 -14
- package/dist/commands/generate-value-object.js.map +1 -1
- package/dist/commands/init-project.d.ts +3 -0
- package/dist/commands/init-project.js +19 -9
- package/dist/commands/init-project.js.map +1 -1
- package/dist/commands/interactive-scaffold.js +10 -0
- package/dist/commands/interactive-scaffold.js.map +1 -1
- package/dist/commands/migration.d.ts +3 -0
- package/dist/commands/migration.js +83 -48
- package/dist/commands/migration.js.map +1 -1
- package/dist/commands/recipe.d.ts +2 -0
- package/dist/commands/recipe.js +282 -91
- package/dist/commands/recipe.js.map +1 -1
- package/dist/commands/recipes/banklink-connector-contract.recipe.d.ts +1 -0
- package/dist/commands/recipes/banklink-connector-contract.recipe.js +161 -0
- package/dist/commands/recipes/banklink-connector-contract.recipe.js.map +1 -0
- package/dist/commands/recipes/bounded-json-http.recipe.d.ts +2 -0
- package/dist/commands/recipes/bounded-json-http.recipe.js +53 -0
- package/dist/commands/recipes/bounded-json-http.recipe.js.map +1 -0
- package/dist/commands/recipes/business-reference-identifiers.recipe.d.ts +1 -0
- package/dist/commands/recipes/business-reference-identifiers.recipe.js +223 -0
- package/dist/commands/recipes/business-reference-identifiers.recipe.js.map +1 -0
- package/dist/commands/recipes/event-backbone.recipe.d.ts +1 -0
- package/dist/commands/recipes/event-backbone.recipe.js +1118 -0
- package/dist/commands/recipes/event-backbone.recipe.js.map +1 -0
- package/dist/commands/recipes/external-projection-worker.recipe.d.ts +6 -0
- package/dist/commands/recipes/external-projection-worker.recipe.js +211 -0
- package/dist/commands/recipes/external-projection-worker.recipe.js.map +1 -0
- package/dist/commands/recipes/index.d.ts +12 -0
- package/dist/commands/recipes/index.js +12 -0
- package/dist/commands/recipes/index.js.map +1 -1
- package/dist/commands/recipes/jest-cjs-compat.recipe.d.ts +3 -0
- package/dist/commands/recipes/jest-cjs-compat.recipe.js +130 -0
- package/dist/commands/recipes/jest-cjs-compat.recipe.js.map +1 -0
- package/dist/commands/recipes/joi-env.recipe.d.ts +1 -0
- package/dist/commands/recipes/joi-env.recipe.js +301 -0
- package/dist/commands/recipes/joi-env.recipe.js.map +1 -0
- package/dist/commands/recipes/kafka-consumer.recipe.d.ts +1 -0
- package/dist/commands/recipes/kafka-consumer.recipe.js +65 -0
- package/dist/commands/recipes/kafka-consumer.recipe.js.map +1 -0
- package/dist/commands/recipes/oauth2-client-credentials.recipe.d.ts +1 -0
- package/dist/commands/recipes/oauth2-client-credentials.recipe.js +60 -0
- package/dist/commands/recipes/oauth2-client-credentials.recipe.js.map +1 -0
- package/dist/commands/recipes/oauth2-token-introspection.recipe.d.ts +1 -0
- package/dist/commands/recipes/oauth2-token-introspection.recipe.js +67 -0
- package/dist/commands/recipes/oauth2-token-introspection.recipe.js.map +1 -0
- package/dist/commands/recipes/oidc-bff-session.recipe.d.ts +1 -0
- package/dist/commands/recipes/oidc-bff-session.recipe.js +57 -0
- package/dist/commands/recipes/oidc-bff-session.recipe.js.map +1 -0
- package/dist/commands/recipes/oidc-dashboard.recipe.d.ts +1 -0
- package/dist/commands/recipes/oidc-dashboard.recipe.js +463 -0
- package/dist/commands/recipes/oidc-dashboard.recipe.js.map +1 -0
- package/dist/commands/recipes/oidc-resource-server.recipe.d.ts +1 -0
- package/dist/commands/recipes/oidc-resource-server.recipe.js +85 -0
- package/dist/commands/recipes/oidc-resource-server.recipe.js.map +1 -0
- package/dist/commands/recipes/platform-context.recipe.d.ts +1 -0
- package/dist/commands/recipes/platform-context.recipe.js +229 -0
- package/dist/commands/recipes/platform-context.recipe.js.map +1 -0
- package/dist/commands/recipes/platform-parc-authorization.recipe.d.ts +1 -0
- package/dist/commands/recipes/platform-parc-authorization.recipe.js +339 -0
- package/dist/commands/recipes/platform-parc-authorization.recipe.js.map +1 -0
- package/dist/commands/recipes/platform-parc-bound-authorization.recipe.d.ts +1 -0
- package/dist/commands/recipes/platform-parc-bound-authorization.recipe.js +56 -0
- package/dist/commands/recipes/platform-parc-bound-authorization.recipe.js.map +1 -0
- package/dist/commands/recipes/platform-service-access-request-context.recipe.d.ts +1 -0
- package/dist/commands/recipes/platform-service-access-request-context.recipe.js +103 -0
- package/dist/commands/recipes/platform-service-access-request-context.recipe.js.map +1 -0
- package/dist/commands/recipes/platform-service-runtime.recipe.d.ts +1 -0
- package/dist/commands/recipes/platform-service-runtime.recipe.js +415 -0
- package/dist/commands/recipes/platform-service-runtime.recipe.js.map +1 -0
- package/dist/index.js +82 -11
- package/dist/index.js.map +1 -1
- package/dist/templates/ai-context/CLAUDE.md.hbs +54 -3
- package/dist/templates/ai-context/conventions.md.hbs +20 -8
- package/dist/templates/command/create-command.hbs +8 -3
- package/dist/templates/command/delete-command.hbs +1 -1
- package/dist/templates/command/update-command.hbs +1 -1
- package/dist/templates/controller/application-controller.hbs +7 -0
- package/dist/templates/controller/controller.hbs +6 -0
- package/dist/templates/dto/create-dto.hbs +17 -5
- package/dist/templates/dto/filter-dto.hbs +1 -1
- package/dist/templates/dto/response-dto.hbs +1 -1
- package/dist/templates/entity/entity.hbs +4 -0
- package/dist/templates/mapper/mapper.hbs +6 -2
- package/dist/templates/orm-entity/orm-entity.hbs +9 -1
- package/dist/templates/prisma/prisma-mapper.hbs +6 -0
- package/dist/templates/prisma/prisma-repository.hbs +57 -5
- package/dist/templates/prisma/prisma-schema.hbs +2 -0
- package/dist/templates/query/get-all.query.hbs +10 -2
- package/dist/templates/recipes/bounded-json-http/README.md.hbs +36 -0
- package/dist/templates/recipes/external-projection-worker/README.md.hbs +157 -0
- package/dist/templates/recipes/external-projection-worker/canonical-json.ts.hbs +123 -0
- package/dist/templates/recipes/external-projection-worker/enqueuer.ts.hbs +167 -0
- package/dist/templates/recipes/external-projection-worker/index.ts.hbs +6 -0
- package/dist/templates/recipes/external-projection-worker/migration.ts.hbs +88 -0
- package/dist/templates/recipes/external-projection-worker/module.ts.hbs +36 -0
- package/dist/templates/recipes/external-projection-worker/store.ts.hbs +354 -0
- package/dist/templates/recipes/external-projection-worker/types.ts.hbs +134 -0
- package/dist/templates/recipes/external-projection-worker/worker.ts.hbs +296 -0
- package/dist/templates/recipes/kafka-consumer/README.md.hbs +50 -0
- package/dist/templates/recipes/kafka-consumer/kafka-consumer.ts.hbs +135 -0
- package/dist/templates/recipes/kafka-consumer/migration.ts.hbs +17 -0
- package/dist/templates/recipes/oauth2-client-credentials/README.md.hbs +40 -0
- package/dist/templates/recipes/oauth2-client-credentials/client.spec.ts.hbs +234 -0
- package/dist/templates/recipes/oauth2-client-credentials/client.ts.hbs +210 -0
- package/dist/templates/recipes/oauth2-client-credentials/index.ts.hbs +2 -0
- package/dist/templates/recipes/oauth2-client-credentials/module.ts.hbs +58 -0
- package/dist/templates/recipes/oauth2-token-introspection/README.md.hbs +158 -0
- package/dist/templates/recipes/oauth2-token-introspection/client.spec.ts.hbs +350 -0
- package/dist/templates/recipes/oauth2-token-introspection/client.ts.hbs +241 -0
- package/dist/templates/recipes/oauth2-token-introspection/http-client.spec.ts.hbs +411 -0
- package/dist/templates/recipes/oauth2-token-introspection/http-client.ts.hbs +343 -0
- package/dist/templates/recipes/oauth2-token-introspection/index.ts.hbs +3 -0
- package/dist/templates/recipes/oauth2-token-introspection/module.ts.hbs +58 -0
- package/dist/templates/recipes/oidc-bff-session/README.md.hbs +96 -0
- package/dist/templates/recipes/oidc-bff-session/client.test.ts.hbs +663 -0
- package/dist/templates/recipes/oidc-bff-session/client.ts.hbs +635 -0
- package/dist/templates/recipes/oidc-bff-session/index.ts.hbs +3 -0
- package/dist/templates/recipes/oidc-bff-session/store.integration.test.ts.hbs +480 -0
- package/dist/templates/recipes/oidc-bff-session/store.test.ts.hbs +87 -0
- package/dist/templates/recipes/oidc-bff-session/store.ts.hbs +549 -0
- package/dist/templates/recipes/oidc-bff-session/valkey.test.ts.hbs +158 -0
- package/dist/templates/recipes/oidc-bff-session/valkey.ts.hbs +148 -0
- package/dist/templates/recipes/oidc-resource-server/README.md.hbs +74 -0
- package/dist/templates/recipes/oidc-resource-server/index.ts.hbs +2 -0
- package/dist/templates/recipes/oidc-resource-server/module.ts.hbs +51 -0
- package/dist/templates/recipes/oidc-resource-server/verifier.spec.ts.hbs +186 -0
- package/dist/templates/recipes/oidc-resource-server/verifier.ts.hbs +440 -0
- package/dist/templates/recipes/platform-parc-bound-authorization/README.md.hbs +92 -0
- package/dist/templates/recipes/platform-parc-bound-authorization/client.ts.hbs +142 -0
- package/dist/templates/recipes/platform-service-access-request-context/docs.md.hbs +61 -0
- package/dist/templates/recipes/platform-service-access-request-context/env.example.hbs +7 -0
- package/dist/templates/recipes/platform-service-access-request-context/index.ts.hbs +7 -0
- package/dist/templates/recipes/platform-service-access-request-context/request-context-headers.ts.hbs +61 -0
- package/dist/templates/recipes/platform-service-access-request-context/request-context-introspection.parser.ts.hbs +132 -0
- package/dist/templates/recipes/platform-service-access-request-context/request-context-introspection.types.ts.hbs +40 -0
- package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.client.ts.hbs +45 -0
- package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.config.ts.hbs +142 -0
- package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.guard.ts.hbs +77 -0
- package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.module.ts.hbs +17 -0
- package/dist/templates/repository/repository.hbs +43 -5
- package/dist/templates/resolver/graphql-input.hbs +23 -6
- package/dist/templates/resolver/resolver.hbs +4 -0
- package/dist/templates/service/domain-service.hbs +2 -2
- package/dist/templates/test/controller.spec.hbs +5 -1
- package/dist/templates/test/prisma-repository.spec.hbs +192 -0
- package/dist/templates/test/repository.spec.hbs +41 -2
- package/dist/templates/test/usecase.spec.hbs +24 -5
- package/dist/templates/usecase/application-usecase.hbs +17 -0
- package/dist/templates/usecase/create-usecase.hbs +34 -3
- package/dist/templates/usecase/update-usecase.hbs +15 -1
- package/dist/utils/config.utils.d.ts +2 -0
- package/dist/utils/config.utils.js +14 -2
- package/dist/utils/config.utils.js.map +1 -1
- package/dist/utils/dependency.utils.d.ts +18 -2
- package/dist/utils/dependency.utils.js +144 -15
- package/dist/utils/dependency.utils.js.map +1 -1
- package/dist/utils/field.utils.d.ts +6 -0
- package/dist/utils/field.utils.js +78 -24
- package/dist/utils/field.utils.js.map +1 -1
- package/dist/utils/field.utils.spec.d.ts +1 -0
- package/dist/utils/field.utils.spec.js +38 -0
- package/dist/utils/field.utils.spec.js.map +1 -0
- package/dist/utils/file.utils.d.ts +39 -3
- package/dist/utils/file.utils.js +150 -13
- package/dist/utils/file.utils.js.map +1 -1
- package/dist/utils/naming.utils.js +5 -4
- package/dist/utils/naming.utils.js.map +1 -1
- package/dist/utils/static-recipe.utils.d.ts +4 -0
- package/dist/utils/static-recipe.utils.js +148 -0
- package/dist/utils/static-recipe.utils.js.map +1 -0
- package/dist/utils/template-engine.utils.js +83 -68
- package/dist/utils/template-engine.utils.js.map +1 -1
- package/package.json +20 -11
|
@@ -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
|
+
}
|