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.
- 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 +54 -28
- 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 +7 -2
- 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 +4 -2
- 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 +229 -85
- 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 +11 -0
- package/dist/commands/recipes/index.js +11 -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.js +1 -1
- 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 +77 -9
- package/dist/index.js.map +1 -1
- package/dist/templates/ai-context/CLAUDE.md.hbs +39 -3
- package/dist/templates/ai-context/conventions.md.hbs +13 -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 +5 -1
- 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 +42 -4
- 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 +40 -1
- package/dist/templates/test/usecase.spec.hbs +23 -4
- 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 +16 -2
- package/dist/utils/dependency.utils.js +100 -12
- package/dist/utils/dependency.utils.js.map +1 -1
- package/dist/utils/field.utils.d.ts +6 -0
- package/dist/utils/field.utils.js +50 -8
- 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 +23 -1
- package/dist/utils/file.utils.js +37 -1
- 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/package.json +12 -5
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import type { ExternalProjectionJsonValue } from './external-projection-worker.types';
|
|
3
|
+
|
|
4
|
+
export const EXTERNAL_PROJECTION_MAX_JSON_BYTES = 262_144;
|
|
5
|
+
const MAX_DEPTH = 32;
|
|
6
|
+
const MAX_NODES = 10_000;
|
|
7
|
+
|
|
8
|
+
export interface CanonicalExternalProjectionJson {
|
|
9
|
+
value: ExternalProjectionJsonValue;
|
|
10
|
+
text: string;
|
|
11
|
+
sha256: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface CanonicalizationState {
|
|
15
|
+
nodes: number;
|
|
16
|
+
stack: WeakSet<object>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface CanonicalNode {
|
|
20
|
+
value: ExternalProjectionJsonValue;
|
|
21
|
+
text: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function canonicalNumber(value: number): CanonicalNode {
|
|
25
|
+
if (!Number.isFinite(value)) {
|
|
26
|
+
throw new TypeError('External projection JSON requires finite numbers');
|
|
27
|
+
}
|
|
28
|
+
const normalized = Object.is(value, -0) ? 0 : value;
|
|
29
|
+
return { value: normalized, text: JSON.stringify(normalized) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function canonicalArray(
|
|
33
|
+
value: unknown[],
|
|
34
|
+
depth: number,
|
|
35
|
+
state: CanonicalizationState,
|
|
36
|
+
): CanonicalNode {
|
|
37
|
+
const children: CanonicalNode[] = [];
|
|
38
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
39
|
+
if (!Object.hasOwn(value, index)) {
|
|
40
|
+
throw new TypeError('External projection JSON cannot contain sparse arrays');
|
|
41
|
+
}
|
|
42
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
43
|
+
if (!descriptor?.enumerable || !('value' in descriptor)) {
|
|
44
|
+
throw new TypeError('External projection JSON arrays require enumerable data elements');
|
|
45
|
+
}
|
|
46
|
+
children.push(canonicalNode(descriptor.value, depth + 1, state));
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
value: children.map((child) => child.value),
|
|
50
|
+
text: `[${children.map((child) => child.text).join(',')}]`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function canonicalObject(
|
|
55
|
+
value: object,
|
|
56
|
+
depth: number,
|
|
57
|
+
state: CanonicalizationState,
|
|
58
|
+
): CanonicalNode {
|
|
59
|
+
const prototype = Object.getPrototypeOf(value) as object | null;
|
|
60
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
61
|
+
throw new TypeError('External projection JSON requires plain objects');
|
|
62
|
+
}
|
|
63
|
+
const properties = Reflect.ownKeys(value);
|
|
64
|
+
if (properties.some((key) => typeof key !== 'string')) {
|
|
65
|
+
throw new TypeError('External projection JSON cannot contain symbol keys');
|
|
66
|
+
}
|
|
67
|
+
const keys = (properties as string[]).sort((left, right) => {
|
|
68
|
+
if (left < right) return -1;
|
|
69
|
+
if (left > right) return 1;
|
|
70
|
+
return 0;
|
|
71
|
+
});
|
|
72
|
+
const output = Object.create(null) as Record<string, ExternalProjectionJsonValue>;
|
|
73
|
+
const entries: string[] = [];
|
|
74
|
+
for (const key of keys) {
|
|
75
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
76
|
+
if (!descriptor?.enumerable || !('value' in descriptor)) {
|
|
77
|
+
throw new TypeError('External projection JSON requires enumerable data properties');
|
|
78
|
+
}
|
|
79
|
+
const child = canonicalNode(descriptor.value, depth + 1, state);
|
|
80
|
+
output[key] = child.value;
|
|
81
|
+
entries.push(`${JSON.stringify(key)}:${child.text}`);
|
|
82
|
+
}
|
|
83
|
+
return { value: output, text: `{${entries.join(',')}}` };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function canonicalNode(value: unknown, depth: number, state: CanonicalizationState): CanonicalNode {
|
|
87
|
+
if (depth > MAX_DEPTH || ++state.nodes > MAX_NODES) {
|
|
88
|
+
throw new Error('External projection JSON exceeds structural limits');
|
|
89
|
+
}
|
|
90
|
+
if (value === null) return { value: null, text: 'null' };
|
|
91
|
+
if (typeof value === 'boolean') return { value, text: value ? 'true' : 'false' };
|
|
92
|
+
if (typeof value === 'string') return { value, text: JSON.stringify(value) };
|
|
93
|
+
if (typeof value === 'number') return canonicalNumber(value);
|
|
94
|
+
if (typeof value !== 'object') {
|
|
95
|
+
throw new TypeError('External projection payload must contain only JSON values');
|
|
96
|
+
}
|
|
97
|
+
if (state.stack.has(value)) throw new Error('External projection JSON cannot contain cycles');
|
|
98
|
+
state.stack.add(value);
|
|
99
|
+
try {
|
|
100
|
+
return Array.isArray(value)
|
|
101
|
+
? canonicalArray(value, depth, state)
|
|
102
|
+
: canonicalObject(value, depth, state);
|
|
103
|
+
} finally {
|
|
104
|
+
state.stack.delete(value);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function canonicalizeExternalProjectionJson(
|
|
109
|
+
value: unknown,
|
|
110
|
+
maxBytes = EXTERNAL_PROJECTION_MAX_JSON_BYTES,
|
|
111
|
+
): CanonicalExternalProjectionJson {
|
|
112
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > 1_048_576) {
|
|
113
|
+
throw new Error('External projection JSON byte limit is invalid');
|
|
114
|
+
}
|
|
115
|
+
const canonical = canonicalNode(value, 0, { nodes: 0, stack: new WeakSet<object>() });
|
|
116
|
+
if (Buffer.byteLength(canonical.text, 'utf8') > maxBytes) {
|
|
117
|
+
throw new Error('External projection JSON exceeds its byte limit');
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
...canonical,
|
|
121
|
+
sha256: createHash('sha256').update(canonical.text).digest('hex'),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { Injectable } from '@nestjs/common';
|
|
3
|
+
import type { EntityManager } from 'typeorm';
|
|
4
|
+
import { canonicalizeExternalProjectionJson } from './canonical-json';
|
|
5
|
+
import {
|
|
6
|
+
EnqueuedExternalProjection,
|
|
7
|
+
ExternalProjectionIdempotencyConflictError,
|
|
8
|
+
ExternalProjectionIntent,
|
|
9
|
+
ExternalProjectionOperation,
|
|
10
|
+
ExternalProjectionStatus,
|
|
11
|
+
} from './external-projection-worker.types';
|
|
12
|
+
|
|
13
|
+
type DatabaseDate = Date | string | null;
|
|
14
|
+
|
|
15
|
+
interface OperationRow {
|
|
16
|
+
id: string;
|
|
17
|
+
projection_name: string;
|
|
18
|
+
target_key: string;
|
|
19
|
+
operation_kind: string;
|
|
20
|
+
idempotency_key: string;
|
|
21
|
+
payload: unknown;
|
|
22
|
+
payload_hash: string;
|
|
23
|
+
status: ExternalProjectionStatus;
|
|
24
|
+
attempt_count: number | string;
|
|
25
|
+
result_reference: string | null;
|
|
26
|
+
next_attempt_at: DatabaseDate;
|
|
27
|
+
last_attempt_at: DatabaseDate;
|
|
28
|
+
applied_at: DatabaseDate;
|
|
29
|
+
last_error_code: string | null;
|
|
30
|
+
created_at: Date | string;
|
|
31
|
+
updated_at: Date | string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function requiredIdentifier(value: string, name: string, maximum: number): string {
|
|
35
|
+
const normalized = value.trim();
|
|
36
|
+
if (
|
|
37
|
+
normalized.length < 1 ||
|
|
38
|
+
normalized.length > maximum ||
|
|
39
|
+
!/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(normalized)
|
|
40
|
+
) {
|
|
41
|
+
throw new Error(`External projection ${name} is invalid`);
|
|
42
|
+
}
|
|
43
|
+
return normalized;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function requiredTargetKey(value: string): string {
|
|
47
|
+
const normalized = value.trim();
|
|
48
|
+
if (
|
|
49
|
+
normalized.length < 1 ||
|
|
50
|
+
normalized.length > 500 ||
|
|
51
|
+
/[\u0000-\u001f\u007f]/.test(normalized)
|
|
52
|
+
) {
|
|
53
|
+
throw new Error('External projection target key is invalid');
|
|
54
|
+
}
|
|
55
|
+
return normalized;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function rowsFromQuery<T>(result: unknown): T[] {
|
|
59
|
+
if (!Array.isArray(result)) return [];
|
|
60
|
+
return Array.isArray(result[0]) ? (result[0] as T[]) : (result as T[]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function optionalDate(value: Date | string | null): Date | undefined {
|
|
64
|
+
return value === null ? undefined : new Date(value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function operationFromRow(row: OperationRow): ExternalProjectionOperation {
|
|
68
|
+
const payload = canonicalizeExternalProjectionJson(row.payload).value;
|
|
69
|
+
return {
|
|
70
|
+
id: row.id,
|
|
71
|
+
projectionName: row.projection_name,
|
|
72
|
+
targetKey: row.target_key,
|
|
73
|
+
operationKind: row.operation_kind,
|
|
74
|
+
idempotencyKey: row.idempotency_key,
|
|
75
|
+
payload,
|
|
76
|
+
payloadHash: row.payload_hash,
|
|
77
|
+
status: row.status,
|
|
78
|
+
attemptCount: Number(row.attempt_count),
|
|
79
|
+
resultReference: row.result_reference ?? undefined,
|
|
80
|
+
nextAttemptAt: optionalDate(row.next_attempt_at),
|
|
81
|
+
lastAttemptAt: optionalDate(row.last_attempt_at),
|
|
82
|
+
appliedAt: optionalDate(row.applied_at),
|
|
83
|
+
lastErrorCode: row.last_error_code ?? undefined,
|
|
84
|
+
createdAt: new Date(row.created_at),
|
|
85
|
+
updatedAt: new Date(row.updated_at),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@Injectable()
|
|
90
|
+
export class ExternalProjectionEnqueuer {
|
|
91
|
+
async enqueue(
|
|
92
|
+
manager: EntityManager,
|
|
93
|
+
input: ExternalProjectionIntent,
|
|
94
|
+
): Promise<EnqueuedExternalProjection> {
|
|
95
|
+
if (
|
|
96
|
+
!manager ||
|
|
97
|
+
typeof manager.query !== 'function' ||
|
|
98
|
+
manager.queryRunner?.isTransactionActive !== true
|
|
99
|
+
) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
'External projection enqueue requires an active transaction-bound EntityManager',
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
const projectionName = requiredIdentifier(input.projectionName, 'name', 120);
|
|
105
|
+
const targetKey = requiredTargetKey(input.targetKey);
|
|
106
|
+
const operationKind = requiredIdentifier(input.operationKind, 'operation kind', 120);
|
|
107
|
+
const idempotencyKey = requiredIdentifier(input.idempotencyKey, 'idempotency key', 200);
|
|
108
|
+
const payload = canonicalizeExternalProjectionJson(input.payload);
|
|
109
|
+
const envelope = canonicalizeExternalProjectionJson({
|
|
110
|
+
operationKind,
|
|
111
|
+
payload: payload.value,
|
|
112
|
+
projectionName,
|
|
113
|
+
targetKey,
|
|
114
|
+
});
|
|
115
|
+
await manager.query(
|
|
116
|
+
`INSERT INTO external_projection_target_heads (projection_name, target_key)
|
|
117
|
+
VALUES ($1, $2)
|
|
118
|
+
ON CONFLICT (projection_name, target_key) DO NOTHING`,
|
|
119
|
+
[projectionName, targetKey],
|
|
120
|
+
);
|
|
121
|
+
const lockedTarget = rowsFromQuery<{ projection_name: string }>(
|
|
122
|
+
await manager.query(
|
|
123
|
+
`SELECT projection_name
|
|
124
|
+
FROM external_projection_target_heads
|
|
125
|
+
WHERE projection_name = $1 AND target_key = $2
|
|
126
|
+
FOR UPDATE`,
|
|
127
|
+
[projectionName, targetKey],
|
|
128
|
+
),
|
|
129
|
+
);
|
|
130
|
+
if (lockedTarget.length !== 1) {
|
|
131
|
+
throw new Error('External projection target serialization lock was not acquired');
|
|
132
|
+
}
|
|
133
|
+
const inserted = rowsFromQuery<OperationRow>(
|
|
134
|
+
await manager.query(
|
|
135
|
+
`INSERT INTO external_projection_operations (
|
|
136
|
+
id, projection_name, target_key, operation_kind, idempotency_key, payload, payload_hash
|
|
137
|
+
) VALUES ($1, $2, $3, $4, $5, $6::text::jsonb, $7)
|
|
138
|
+
ON CONFLICT (projection_name, idempotency_key) DO NOTHING
|
|
139
|
+
RETURNING *`,
|
|
140
|
+
[
|
|
141
|
+
randomUUID(),
|
|
142
|
+
projectionName,
|
|
143
|
+
targetKey,
|
|
144
|
+
operationKind,
|
|
145
|
+
idempotencyKey,
|
|
146
|
+
payload.text,
|
|
147
|
+
envelope.sha256,
|
|
148
|
+
],
|
|
149
|
+
),
|
|
150
|
+
);
|
|
151
|
+
const created = inserted[0];
|
|
152
|
+
if (created) return { operation: operationFromRow(created), created: true };
|
|
153
|
+
|
|
154
|
+
const existing = rowsFromQuery<OperationRow>(
|
|
155
|
+
await manager.query(
|
|
156
|
+
`SELECT * FROM external_projection_operations
|
|
157
|
+
WHERE projection_name = $1 AND idempotency_key = $2`,
|
|
158
|
+
[projectionName, idempotencyKey],
|
|
159
|
+
),
|
|
160
|
+
)[0];
|
|
161
|
+
if (!existing) throw new Error('External projection idempotency lookup failed');
|
|
162
|
+
if (existing.payload_hash !== envelope.sha256) {
|
|
163
|
+
throw new ExternalProjectionIdempotencyConflictError();
|
|
164
|
+
}
|
|
165
|
+
return { operation: operationFromRow(existing), created: false };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export * from './canonical-json';
|
|
2
|
+
export * from './external-projection-enqueuer';
|
|
3
|
+
export * from './external-projection-store';
|
|
4
|
+
export * from './external-projection-worker';
|
|
5
|
+
export * from './external-projection-worker.module';
|
|
6
|
+
export * from './external-projection-worker.types';
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
|
2
|
+
|
|
3
|
+
// Covers exponent-free PostgreSQL rendering for the canonicalizer's 10,000-node limit.
|
|
4
|
+
const EXTERNAL_PROJECTION_MAX_JSONB_RENDERED_BYTES = 4_194_304;
|
|
5
|
+
|
|
6
|
+
export class CreateExternalProjectionOperationsMIGRATIONTIMESTAMPPLACEHOLDER
|
|
7
|
+
implements MigrationInterface
|
|
8
|
+
{
|
|
9
|
+
async up(queryRunner: QueryRunner): Promise<void> {
|
|
10
|
+
await queryRunner.query(`CREATE TABLE external_projection_target_heads (
|
|
11
|
+
projection_name varchar(120) NOT NULL,
|
|
12
|
+
target_key varchar(500) NOT NULL,
|
|
13
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
14
|
+
PRIMARY KEY (projection_name, target_key)
|
|
15
|
+
)`);
|
|
16
|
+
await queryRunner.query(`CREATE TABLE external_projection_operations (
|
|
17
|
+
id uuid PRIMARY KEY,
|
|
18
|
+
enqueue_sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE,
|
|
19
|
+
projection_name varchar(120) NOT NULL,
|
|
20
|
+
target_key varchar(500) NOT NULL,
|
|
21
|
+
operation_kind varchar(120) NOT NULL,
|
|
22
|
+
idempotency_key varchar(200) NOT NULL,
|
|
23
|
+
payload jsonb NOT NULL,
|
|
24
|
+
payload_hash char(64) NOT NULL,
|
|
25
|
+
status varchar(20) NOT NULL DEFAULT 'pending',
|
|
26
|
+
attempt_count integer NOT NULL DEFAULT 0,
|
|
27
|
+
result_reference varchar(500),
|
|
28
|
+
lease_owner varchar(200),
|
|
29
|
+
lease_token uuid,
|
|
30
|
+
lease_expires_at timestamptz,
|
|
31
|
+
next_attempt_at timestamptz,
|
|
32
|
+
last_attempt_at timestamptz,
|
|
33
|
+
applied_at timestamptz,
|
|
34
|
+
last_error_code varchar(160),
|
|
35
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
36
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
37
|
+
CONSTRAINT fk_external_projection_target_head
|
|
38
|
+
FOREIGN KEY (projection_name, target_key)
|
|
39
|
+
REFERENCES external_projection_target_heads (projection_name, target_key),
|
|
40
|
+
CONSTRAINT uq_external_projection_idempotency
|
|
41
|
+
UNIQUE (projection_name, idempotency_key),
|
|
42
|
+
CONSTRAINT ck_external_projection_status
|
|
43
|
+
CHECK (status IN ('pending', 'processing', 'retry_wait', 'uncertain', 'blocked', 'applied')),
|
|
44
|
+
CONSTRAINT ck_external_projection_attempts CHECK (attempt_count >= 0),
|
|
45
|
+
CONSTRAINT ck_external_projection_payload_hash
|
|
46
|
+
CHECK (payload_hash ~ '^[0-9a-f]{64}$'),
|
|
47
|
+
CONSTRAINT ck_external_projection_payload_size
|
|
48
|
+
CHECK (octet_length(payload::text) <= ${EXTERNAL_PROJECTION_MAX_JSONB_RENDERED_BYTES}),
|
|
49
|
+
CONSTRAINT ck_external_projection_lease
|
|
50
|
+
CHECK (
|
|
51
|
+
(status = 'processing' AND lease_owner IS NOT NULL AND lease_token IS NOT NULL AND lease_expires_at IS NOT NULL)
|
|
52
|
+
OR
|
|
53
|
+
(status <> 'processing' AND lease_owner IS NULL AND lease_token IS NULL AND lease_expires_at IS NULL)
|
|
54
|
+
),
|
|
55
|
+
CONSTRAINT ck_external_projection_retry_time
|
|
56
|
+
CHECK (
|
|
57
|
+
(status IN ('retry_wait', 'uncertain') AND next_attempt_at IS NOT NULL)
|
|
58
|
+
OR
|
|
59
|
+
(status NOT IN ('retry_wait', 'uncertain') AND next_attempt_at IS NULL)
|
|
60
|
+
),
|
|
61
|
+
CONSTRAINT ck_external_projection_applied_time
|
|
62
|
+
CHECK (
|
|
63
|
+
(status = 'applied' AND applied_at IS NOT NULL)
|
|
64
|
+
OR
|
|
65
|
+
(status <> 'applied' AND applied_at IS NULL)
|
|
66
|
+
),
|
|
67
|
+
CONSTRAINT ck_external_projection_error
|
|
68
|
+
CHECK (
|
|
69
|
+
(status IN ('retry_wait', 'uncertain', 'blocked') AND last_error_code IS NOT NULL)
|
|
70
|
+
OR
|
|
71
|
+
(status NOT IN ('retry_wait', 'uncertain', 'blocked') AND last_error_code IS NULL)
|
|
72
|
+
)
|
|
73
|
+
)`);
|
|
74
|
+
await queryRunner.query(`CREATE INDEX ix_external_projection_dispatch
|
|
75
|
+
ON external_projection_operations (
|
|
76
|
+
projection_name, status, next_attempt_at, lease_expires_at, enqueue_sequence
|
|
77
|
+
)`);
|
|
78
|
+
await queryRunner.query(`CREATE INDEX ix_external_projection_target
|
|
79
|
+
ON external_projection_operations (
|
|
80
|
+
projection_name, target_key, enqueue_sequence, status
|
|
81
|
+
)`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async down(queryRunner: QueryRunner): Promise<void> {
|
|
85
|
+
await queryRunner.query('DROP TABLE external_projection_operations');
|
|
86
|
+
await queryRunner.query('DROP TABLE external_projection_target_heads');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { DynamicModule, Module, ModuleMetadata, Type } from '@nestjs/common';
|
|
2
|
+
import { ExternalProjectionEnqueuer } from './external-projection-enqueuer';
|
|
3
|
+
import { ExternalProjectionStore } from './external-projection-store';
|
|
4
|
+
import {
|
|
5
|
+
EXTERNAL_PROJECTION_HANDLER,
|
|
6
|
+
EXTERNAL_PROJECTION_WORKER_OPTIONS,
|
|
7
|
+
ExternalProjectionWorker,
|
|
8
|
+
validateExternalProjectionWorkerOptions,
|
|
9
|
+
} from './external-projection-worker';
|
|
10
|
+
import {
|
|
11
|
+
ExternalProjectionHandler,
|
|
12
|
+
ExternalProjectionWorkerOptions,
|
|
13
|
+
} from './external-projection-worker.types';
|
|
14
|
+
|
|
15
|
+
@Module({})
|
|
16
|
+
export class ExternalProjectionWorkerModule {
|
|
17
|
+
static register(
|
|
18
|
+
options: ExternalProjectionWorkerOptions,
|
|
19
|
+
handler: Type<ExternalProjectionHandler>,
|
|
20
|
+
imports: ModuleMetadata['imports'] = [],
|
|
21
|
+
): DynamicModule {
|
|
22
|
+
const validated = validateExternalProjectionWorkerOptions(options);
|
|
23
|
+
return {
|
|
24
|
+
module: ExternalProjectionWorkerModule,
|
|
25
|
+
imports,
|
|
26
|
+
providers: [
|
|
27
|
+
{ provide: EXTERNAL_PROJECTION_WORKER_OPTIONS, useValue: validated },
|
|
28
|
+
{ provide: EXTERNAL_PROJECTION_HANDLER, useExisting: handler },
|
|
29
|
+
ExternalProjectionEnqueuer,
|
|
30
|
+
ExternalProjectionStore,
|
|
31
|
+
ExternalProjectionWorker,
|
|
32
|
+
],
|
|
33
|
+
exports: [ExternalProjectionEnqueuer],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
}
|