@apso/cli 0.16.0 → 0.17.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/dist/commands/generate.js +30 -6
- package/dist/commands/mcp/serve.js +14 -0
- package/dist/lib/apsorc-parser.d.ts +8 -0
- package/dist/lib/apsorc-parser.js +4 -0
- package/dist/lib/base-command.d.ts +1 -0
- package/dist/lib/base-command.js +7 -4
- package/dist/lib/events.d.ts +31 -0
- package/dist/lib/events.js +43 -0
- package/dist/lib/generators/base.d.ts +11 -1
- package/dist/lib/generators/base.js +8 -0
- package/dist/lib/generators/typescript.d.ts +18 -1
- package/dist/lib/generators/typescript.js +62 -2
- package/dist/lib/index.d.ts +1 -0
- package/dist/lib/index.js +5 -1
- package/dist/lib/templates/events/domain-event.entity.eta +51 -0
- package/dist/lib/templates/events/domain-event.mapper.eta +62 -0
- package/dist/lib/templates/events/domain-event.relay.eta +86 -0
- package/dist/lib/templates/events/domain-event.subscriber.eta +118 -0
- package/dist/lib/templates/events/domain-events.module.eta +40 -0
- package/dist/lib/templates/events/index.eta +7 -0
- package/dist/lib/templates/graphql/index-module-graphql.eta +6 -0
- package/dist/lib/templates/rest/index-module-rest.eta +6 -0
- package/dist/lib/types/entity.d.ts +10 -0
- package/dist/lib/types/generator.d.ts +15 -1
- package/npm-shrinkwrap.json +2 -2
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
|
@@ -13,7 +13,7 @@ class Generate extends base_command_1.default {
|
|
|
13
13
|
const { flags } = await this.parse(Generate);
|
|
14
14
|
const skipFormat = flags["skip-format"];
|
|
15
15
|
const totalBuildStart = perf_hooks_1.performance.now();
|
|
16
|
-
const { rootFolder, entities, relationshipMap, apiType, auth, language: configLanguage } = (0, lib_1.parseApsorc)();
|
|
16
|
+
const { rootFolder, entities, relationshipMap, apiType, auth, emitEvents, language: configLanguage } = (0, lib_1.parseApsorc)();
|
|
17
17
|
// Resolve language: flag > .apsorc > prompt
|
|
18
18
|
let language;
|
|
19
19
|
if (flags.language) {
|
|
@@ -52,6 +52,10 @@ class Generate extends base_command_1.default {
|
|
|
52
52
|
const autogenPath = path.join(rootPath, "autogen");
|
|
53
53
|
const lowerCaseApiType = apiType.toLowerCase();
|
|
54
54
|
console.log(`[apso] Generating ${language} code for ${entities.length} entities...`);
|
|
55
|
+
if (entities.length === 0) {
|
|
56
|
+
console.log("[apso] No entities found in .apsorc — nothing to generate. Check that the file exists and defines an `entities` array.");
|
|
57
|
+
}
|
|
58
|
+
let generatedFileCount = 0;
|
|
55
59
|
const generatorConfig = {
|
|
56
60
|
language,
|
|
57
61
|
rootFolder,
|
|
@@ -59,6 +63,7 @@ class Generate extends base_command_1.default {
|
|
|
59
63
|
entities,
|
|
60
64
|
relationshipMap,
|
|
61
65
|
auth,
|
|
66
|
+
emitEvents,
|
|
62
67
|
};
|
|
63
68
|
const generator = (0, lib_1.createGenerator)(generatorConfig);
|
|
64
69
|
const validationResult = generator.validateConfig(generatorConfig);
|
|
@@ -75,6 +80,7 @@ class Generate extends base_command_1.default {
|
|
|
75
80
|
// eslint-disable-next-line no-await-in-loop
|
|
76
81
|
await (0, file_system_1.createFile)(fullPath, file.content);
|
|
77
82
|
}
|
|
83
|
+
generatedFileCount += enumFiles.length;
|
|
78
84
|
// Generate shared query utilities
|
|
79
85
|
const queryUtilFiles = await generator.generateQueryUtils(entities, lowerCaseApiType);
|
|
80
86
|
for (const file of queryUtilFiles) {
|
|
@@ -82,6 +88,7 @@ class Generate extends base_command_1.default {
|
|
|
82
88
|
// eslint-disable-next-line no-await-in-loop
|
|
83
89
|
await (0, file_system_1.createFile)(fullPath, file.content);
|
|
84
90
|
}
|
|
91
|
+
generatedFileCount += queryUtilFiles.length;
|
|
85
92
|
// Generate per-entity files
|
|
86
93
|
for (const entity of entities) {
|
|
87
94
|
console.log(`[apso] Building... ${entity.name}`);
|
|
@@ -128,6 +135,7 @@ class Generate extends base_command_1.default {
|
|
|
128
135
|
const fullPath = path.join(autogenPath, file.path);
|
|
129
136
|
// eslint-disable-next-line no-await-in-loop
|
|
130
137
|
await (0, file_system_1.createFile)(fullPath, file.content);
|
|
138
|
+
generatedFileCount += 1;
|
|
131
139
|
}
|
|
132
140
|
}
|
|
133
141
|
const entityBuildTime = perf_hooks_1.performance.now() - entityBuildStart;
|
|
@@ -140,24 +148,40 @@ class Generate extends base_command_1.default {
|
|
|
140
148
|
// eslint-disable-next-line no-await-in-loop
|
|
141
149
|
await (0, file_system_1.createFile)(fullPath, file.content);
|
|
142
150
|
}
|
|
151
|
+
generatedFileCount += guardFiles.length;
|
|
152
|
+
// Generate domain-event spine (only when at least one entity opts in)
|
|
153
|
+
const domainEventFiles = await generator.generateDomainEvents(entities, lowerCaseApiType, { emitEvents });
|
|
154
|
+
for (const file of domainEventFiles) {
|
|
155
|
+
const fullPath = path.join(autogenPath, file.path);
|
|
156
|
+
// eslint-disable-next-line no-await-in-loop
|
|
157
|
+
await (0, file_system_1.createFile)(fullPath, file.content);
|
|
158
|
+
}
|
|
143
159
|
// Generate index module
|
|
144
|
-
const indexFiles = await generator.generateIndexModule(entities, lowerCaseApiType);
|
|
160
|
+
const indexFiles = await generator.generateIndexModule(entities, lowerCaseApiType, { emitEvents });
|
|
145
161
|
for (const file of indexFiles) {
|
|
146
162
|
const fullPath = path.join(autogenPath, file.path);
|
|
147
163
|
// eslint-disable-next-line no-await-in-loop
|
|
148
164
|
await (0, file_system_1.createFile)(fullPath, file.content);
|
|
149
165
|
}
|
|
150
|
-
|
|
151
|
-
|
|
166
|
+
generatedFileCount += indexFiles.length;
|
|
167
|
+
// Format generated files (TypeScript only).
|
|
168
|
+
// Run prettier directly against the generated output directory so we never
|
|
169
|
+
// reformat hand-written code elsewhere in the project tree (the project's
|
|
170
|
+
// `npm run format` script targets a broad glob like `{src,test}/**/*.ts`).
|
|
171
|
+
if (language === "typescript" && !skipFormat && generatedFileCount > 0) {
|
|
152
172
|
const formatStart = perf_hooks_1.performance.now();
|
|
153
|
-
console.log("[apso] Formatting files...");
|
|
154
|
-
|
|
173
|
+
console.log("[apso] Formatting generated files...");
|
|
174
|
+
const formatGlob = path.join(autogenPath, "**", "*.ts");
|
|
175
|
+
await this.runCommand("npx", ["prettier", "--write", formatGlob], true);
|
|
155
176
|
const formatTime = perf_hooks_1.performance.now() - formatStart;
|
|
156
177
|
console.log(`[apso] Finished formatting in ${formatTime.toFixed(2)} ms`);
|
|
157
178
|
}
|
|
158
179
|
else if (skipFormat) {
|
|
159
180
|
console.log("[apso] Skipping formatting (--skip-format flag set)");
|
|
160
181
|
}
|
|
182
|
+
else if (generatedFileCount === 0) {
|
|
183
|
+
console.log("[apso] Skipping formatting (no files were generated)");
|
|
184
|
+
}
|
|
161
185
|
const totalBuildTime = perf_hooks_1.performance.now() - totalBuildStart;
|
|
162
186
|
console.log(`[apso] Finished building all entities in ${totalBuildTime.toFixed(2)} ms`);
|
|
163
187
|
}
|
|
@@ -923,6 +923,7 @@ const INLINE_SCHEMA_REFERENCE = `# Schema Quick Reference
|
|
|
923
923
|
"updated_at": true,
|
|
924
924
|
"primaryKeyType": "serial",
|
|
925
925
|
"scopeBy": "organizationId",
|
|
926
|
+
"emitEvents": true,
|
|
926
927
|
"fields": [
|
|
927
928
|
{ "name": "fieldName", "type": "text", "length": 100 }
|
|
928
929
|
],
|
|
@@ -932,6 +933,19 @@ const INLINE_SCHEMA_REFERENCE = `# Schema Quick Reference
|
|
|
932
933
|
}
|
|
933
934
|
\`\`\`
|
|
934
935
|
|
|
936
|
+
## Domain Events (emitEvents)
|
|
937
|
+
Set \`emitEvents: true\` (per-entity or as a top-level default) to durably log
|
|
938
|
+
state changes. A top-level \`emitEvents: true\` enables it for every entity; an
|
|
939
|
+
individual entity can opt out with \`emitEvents: false\` (effective value is
|
|
940
|
+
\`entity.emitEvents ?? <top-level emitEvents> ?? false\`).
|
|
941
|
+
|
|
942
|
+
When at least one entity opts in, the generator emits a \`DomainEvent\` spine
|
|
943
|
+
under \`autogen/events/\`: a \`DomainEvent\` entity (table \`events\`), a TypeORM
|
|
944
|
+
\`DomainEventSubscriber\` that writes events in the SAME DB transaction as the
|
|
945
|
+
change, an app-overridable \`DomainEventMapper\` (event-type + payload), and a
|
|
946
|
+
\`DomainEventRelay\` whose \`publish()\` you override to deliver events. The
|
|
947
|
+
\`DomainEventsModule\` is wired into the generated module list automatically.
|
|
948
|
+
|
|
935
949
|
## Relationship Types
|
|
936
950
|
- OneToMany: parent has many children
|
|
937
951
|
- ManyToOne: child belongs to parent
|
|
@@ -14,6 +14,12 @@ export type ApsorcType = {
|
|
|
14
14
|
relationships: ApsorcRelationship[];
|
|
15
15
|
auth?: AuthConfig;
|
|
16
16
|
language?: TargetLanguage;
|
|
17
|
+
/**
|
|
18
|
+
* Top-level default for the opt-in DomainEvent ("emitEvents") feature.
|
|
19
|
+
* When true, every entity emits domain events unless it opts out with
|
|
20
|
+
* its own `emitEvents: false`.
|
|
21
|
+
*/
|
|
22
|
+
emitEvents?: boolean;
|
|
17
23
|
};
|
|
18
24
|
type ParsedApsorcData = {
|
|
19
25
|
entities: Entity[];
|
|
@@ -26,6 +32,8 @@ type ParsedApsorc = {
|
|
|
26
32
|
relationshipMap: RelationshipMap;
|
|
27
33
|
auth?: AuthConfig;
|
|
28
34
|
language?: TargetLanguage;
|
|
35
|
+
/** Top-level default for the DomainEvent ("emitEvents") feature. */
|
|
36
|
+
emitEvents?: boolean;
|
|
29
37
|
};
|
|
30
38
|
export declare const parseApsorcV1: (apsorc: ApsorcType) => ParsedApsorcData;
|
|
31
39
|
export declare const parseApsorcV2: (apsorc: ApsorcType) => ParsedApsorcData;
|
|
@@ -36,6 +36,7 @@ const parseRc = () => {
|
|
|
36
36
|
const relationships = apsoConfig.relationships || [];
|
|
37
37
|
const auth = apsoConfig.auth;
|
|
38
38
|
const language = apsoConfig.language;
|
|
39
|
+
const emitEvents = apsoConfig.emitEvents;
|
|
39
40
|
return {
|
|
40
41
|
rootFolder,
|
|
41
42
|
apiType,
|
|
@@ -44,6 +45,7 @@ const parseRc = () => {
|
|
|
44
45
|
relationships,
|
|
45
46
|
auth,
|
|
46
47
|
language,
|
|
48
|
+
emitEvents,
|
|
47
49
|
};
|
|
48
50
|
};
|
|
49
51
|
const parseApsorc = () => {
|
|
@@ -63,6 +65,7 @@ const parseApsorc = () => {
|
|
|
63
65
|
apiType: apsoConfig.apiType,
|
|
64
66
|
auth: apsoConfig.auth,
|
|
65
67
|
language: apsoConfig.language,
|
|
68
|
+
emitEvents: apsoConfig.emitEvents,
|
|
66
69
|
...(0, exports.parseApsorcV1)(apsoConfig),
|
|
67
70
|
};
|
|
68
71
|
if (debug) {
|
|
@@ -79,6 +82,7 @@ const parseApsorc = () => {
|
|
|
79
82
|
apiType: apsoConfig.apiType,
|
|
80
83
|
auth: apsoConfig.auth,
|
|
81
84
|
language: apsoConfig.language,
|
|
85
|
+
emitEvents: apsoConfig.emitEvents,
|
|
82
86
|
...(() => {
|
|
83
87
|
const relStart = perf_hooks_1.performance.now();
|
|
84
88
|
const parsed = (0, exports.parseApsorcV2)(apsoConfig);
|
package/dist/lib/base-command.js
CHANGED
|
@@ -5,14 +5,14 @@ const core_1 = require("@oclif/core");
|
|
|
5
5
|
const child_process_1 = require("child_process");
|
|
6
6
|
const os_1 = tslib_1.__importDefault(require("os"));
|
|
7
7
|
class BaseCommand extends core_1.Command {
|
|
8
|
-
async
|
|
8
|
+
async runCommand(command, args, silent = false) {
|
|
9
9
|
return new Promise((resolve, reject) => {
|
|
10
10
|
const isWindows = os_1.default.platform() === "win32";
|
|
11
|
-
const
|
|
11
|
+
const resolvedCommand = isWindows ? `${command}.cmd` : command;
|
|
12
12
|
const stdio = silent ? "ignore" : "inherit";
|
|
13
|
-
const cmdStr = `${
|
|
13
|
+
const cmdStr = `${resolvedCommand} ${args.join(" ")}`;
|
|
14
14
|
this.log(`Running: ${cmdStr}`);
|
|
15
|
-
const child = (0, child_process_1.spawn)(
|
|
15
|
+
const child = (0, child_process_1.spawn)(resolvedCommand, args, {
|
|
16
16
|
stdio,
|
|
17
17
|
shell: isWindows,
|
|
18
18
|
});
|
|
@@ -28,5 +28,8 @@ class BaseCommand extends core_1.Command {
|
|
|
28
28
|
});
|
|
29
29
|
});
|
|
30
30
|
}
|
|
31
|
+
async runNpmCommand(args, silent = false) {
|
|
32
|
+
return this.runCommand("npm", args, silent);
|
|
33
|
+
}
|
|
31
34
|
}
|
|
32
35
|
exports.default = BaseCommand;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Entity } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* Helpers for the opt-in DomainEvent ("emitEvents") feature.
|
|
4
|
+
*
|
|
5
|
+
* This implements the standard transactional-outbox pattern for durability, but
|
|
6
|
+
* is surfaced with generic "domain event" naming (the user found "outbox"
|
|
7
|
+
* unintuitive). No public artifact is named "outbox".
|
|
8
|
+
*
|
|
9
|
+
* Flag resolution: the effective per-entity value is
|
|
10
|
+
* `entity.emitEvents ?? globalEmitEvents ?? false`
|
|
11
|
+
* so a top-level `emitEvents: true` enables it for every entity, while an
|
|
12
|
+
* individual entity can opt out with `emitEvents: false` (global default WITH
|
|
13
|
+
* per-entity opt-out).
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Computes whether a single entity has domain events enabled, given the
|
|
17
|
+
* top-level (global) default.
|
|
18
|
+
*/
|
|
19
|
+
export declare function isEmitEventsEnabled(entity: Entity, globalEmitEvents?: boolean): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Computes the set of entities that have opted in to domain-event emission.
|
|
22
|
+
* This is the single source of truth used everywhere (generator, wiring,
|
|
23
|
+
* subscriber scoping).
|
|
24
|
+
*/
|
|
25
|
+
export declare function getEventEmittingEntities(entities: Entity[], globalEmitEvents?: boolean): Entity[];
|
|
26
|
+
/**
|
|
27
|
+
* Returns true when at least one entity has domain events enabled, meaning the
|
|
28
|
+
* DomainEvent spine (entity, subscriber, mapper, relay, module) should be
|
|
29
|
+
* generated.
|
|
30
|
+
*/
|
|
31
|
+
export declare function hasEventEmittingEntities(entities: Entity[], globalEmitEvents?: boolean): boolean;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.hasEventEmittingEntities = exports.getEventEmittingEntities = exports.isEmitEventsEnabled = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Helpers for the opt-in DomainEvent ("emitEvents") feature.
|
|
6
|
+
*
|
|
7
|
+
* This implements the standard transactional-outbox pattern for durability, but
|
|
8
|
+
* is surfaced with generic "domain event" naming (the user found "outbox"
|
|
9
|
+
* unintuitive). No public artifact is named "outbox".
|
|
10
|
+
*
|
|
11
|
+
* Flag resolution: the effective per-entity value is
|
|
12
|
+
* `entity.emitEvents ?? globalEmitEvents ?? false`
|
|
13
|
+
* so a top-level `emitEvents: true` enables it for every entity, while an
|
|
14
|
+
* individual entity can opt out with `emitEvents: false` (global default WITH
|
|
15
|
+
* per-entity opt-out).
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Computes whether a single entity has domain events enabled, given the
|
|
19
|
+
* top-level (global) default.
|
|
20
|
+
*/
|
|
21
|
+
function isEmitEventsEnabled(entity, globalEmitEvents) {
|
|
22
|
+
var _a, _b;
|
|
23
|
+
return (_b = (_a = entity.emitEvents) !== null && _a !== void 0 ? _a : globalEmitEvents) !== null && _b !== void 0 ? _b : false;
|
|
24
|
+
}
|
|
25
|
+
exports.isEmitEventsEnabled = isEmitEventsEnabled;
|
|
26
|
+
/**
|
|
27
|
+
* Computes the set of entities that have opted in to domain-event emission.
|
|
28
|
+
* This is the single source of truth used everywhere (generator, wiring,
|
|
29
|
+
* subscriber scoping).
|
|
30
|
+
*/
|
|
31
|
+
function getEventEmittingEntities(entities, globalEmitEvents) {
|
|
32
|
+
return entities.filter((entity) => isEmitEventsEnabled(entity, globalEmitEvents));
|
|
33
|
+
}
|
|
34
|
+
exports.getEventEmittingEntities = getEventEmittingEntities;
|
|
35
|
+
/**
|
|
36
|
+
* Returns true when at least one entity has domain events enabled, meaning the
|
|
37
|
+
* DomainEvent spine (entity, subscriber, mapper, relay, module) should be
|
|
38
|
+
* generated.
|
|
39
|
+
*/
|
|
40
|
+
function hasEventEmittingEntities(entities, globalEmitEvents) {
|
|
41
|
+
return entities.some((entity) => isEmitEventsEnabled(entity, globalEmitEvents));
|
|
42
|
+
}
|
|
43
|
+
exports.hasEventEmittingEntities = hasEventEmittingEntities;
|
|
@@ -52,7 +52,9 @@ export declare abstract class BaseGenerator implements LanguageGenerator {
|
|
|
52
52
|
/**
|
|
53
53
|
* Generate the index/main module file
|
|
54
54
|
*/
|
|
55
|
-
abstract generateIndexModule(entities: Entity[], apiType: string
|
|
55
|
+
abstract generateIndexModule(entities: Entity[], apiType: string, opts?: {
|
|
56
|
+
emitEvents?: boolean;
|
|
57
|
+
}): Promise<GeneratedFile[]>;
|
|
56
58
|
/**
|
|
57
59
|
* Generate guard files (auth, scope, etc.)
|
|
58
60
|
*/
|
|
@@ -62,6 +64,14 @@ export declare abstract class BaseGenerator implements LanguageGenerator {
|
|
|
62
64
|
* Default no-op. Override in language generators that need query utils.
|
|
63
65
|
*/
|
|
64
66
|
generateQueryUtils(_entities: Entity[], _apiType: string): Promise<GeneratedFile[]>;
|
|
67
|
+
/**
|
|
68
|
+
* Generate the durable DomainEvent spine for entities that opt in to
|
|
69
|
+
* `emitEvents` (transactional-outbox pattern, generic domain-event naming).
|
|
70
|
+
* Default no-op. Override in language generators that support it.
|
|
71
|
+
*/
|
|
72
|
+
generateDomainEvents(_entities: Entity[], _apiType?: string, _opts?: {
|
|
73
|
+
emitEvents?: boolean;
|
|
74
|
+
}): Promise<GeneratedFile[]>;
|
|
65
75
|
/**
|
|
66
76
|
* Render a template file with the given data
|
|
67
77
|
*/
|
|
@@ -27,6 +27,14 @@ class BaseGenerator {
|
|
|
27
27
|
async generateQueryUtils(_entities, _apiType) {
|
|
28
28
|
return [];
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Generate the durable DomainEvent spine for entities that opt in to
|
|
32
|
+
* `emitEvents` (transactional-outbox pattern, generic domain-event naming).
|
|
33
|
+
* Default no-op. Override in language generators that support it.
|
|
34
|
+
*/
|
|
35
|
+
async generateDomainEvents(_entities, _apiType, _opts) {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
30
38
|
/**
|
|
31
39
|
* Render a template file with the given data
|
|
32
40
|
*/
|
|
@@ -16,8 +16,25 @@ export declare class TypeScriptGenerator extends BaseGenerator {
|
|
|
16
16
|
generateModule(options: EntityGenerationOptions): Promise<GeneratedFile[]>;
|
|
17
17
|
generateMigration(_options: MigrationGenerationOptions): Promise<GeneratedFile[]>;
|
|
18
18
|
generateEnums(entities: Entity[], apiType: string): Promise<GeneratedFile[]>;
|
|
19
|
-
generateIndexModule(entities: Entity[], apiType: string
|
|
19
|
+
generateIndexModule(entities: Entity[], apiType: string, opts?: {
|
|
20
|
+
emitEvents?: boolean;
|
|
21
|
+
}): Promise<GeneratedFile[]>;
|
|
20
22
|
generateGuards(entities: Entity[], auth?: AuthConfig): Promise<GeneratedFile[]>;
|
|
23
|
+
/**
|
|
24
|
+
* Generates the durable DomainEvent spine (transactional-outbox pattern,
|
|
25
|
+
* surfaced with generic domain-event naming) when at least one entity has
|
|
26
|
+
* opted in to `emitEvents`.
|
|
27
|
+
*
|
|
28
|
+
* Returns an empty array when no entity is opted in, so callers can skip
|
|
29
|
+
* wiring entirely.
|
|
30
|
+
*
|
|
31
|
+
* @param entities All entities from the configuration
|
|
32
|
+
* @param _apiType API type (unused; events are REST/Graphql agnostic)
|
|
33
|
+
* @param opts.emitEvents Top-level (global) default for emitEvents
|
|
34
|
+
*/
|
|
35
|
+
generateDomainEvents(entities: Entity[], _apiType?: string, opts?: {
|
|
36
|
+
emitEvents?: boolean;
|
|
37
|
+
}): Promise<GeneratedFile[]>;
|
|
21
38
|
/**
|
|
22
39
|
* Normalizes auth config by applying defaults for DB session providers
|
|
23
40
|
*/
|
|
@@ -15,6 +15,7 @@ const fs = tslib_1.__importStar(require("fs"));
|
|
|
15
15
|
const os = tslib_1.__importStar(require("os"));
|
|
16
16
|
const util_1 = require("util");
|
|
17
17
|
const guards_1 = require("../guards");
|
|
18
|
+
const events_1 = require("../events");
|
|
18
19
|
const auth_1 = require("../types/auth");
|
|
19
20
|
const unlinkAsync = (0, util_1.promisify)(fs.unlink);
|
|
20
21
|
/**
|
|
@@ -302,8 +303,9 @@ class TypeScriptGenerator extends base_1.BaseGenerator {
|
|
|
302
303
|
},
|
|
303
304
|
];
|
|
304
305
|
}
|
|
305
|
-
async generateIndexModule(entities, apiType) {
|
|
306
|
-
const
|
|
306
|
+
async generateIndexModule(entities, apiType, opts) {
|
|
307
|
+
const emitDomainEvents = (0, events_1.getEventEmittingEntities)(entities, opts === null || opts === void 0 ? void 0 : opts.emitEvents).length > 0;
|
|
308
|
+
const content = await this.renderTemplate(`./${apiType}/index-module-${apiType}`, { entities, emitDomainEvents });
|
|
307
309
|
return [
|
|
308
310
|
{
|
|
309
311
|
path: "index.ts",
|
|
@@ -355,6 +357,64 @@ class TypeScriptGenerator extends base_1.BaseGenerator {
|
|
|
355
357
|
});
|
|
356
358
|
return files;
|
|
357
359
|
}
|
|
360
|
+
/**
|
|
361
|
+
* Generates the durable DomainEvent spine (transactional-outbox pattern,
|
|
362
|
+
* surfaced with generic domain-event naming) when at least one entity has
|
|
363
|
+
* opted in to `emitEvents`.
|
|
364
|
+
*
|
|
365
|
+
* Returns an empty array when no entity is opted in, so callers can skip
|
|
366
|
+
* wiring entirely.
|
|
367
|
+
*
|
|
368
|
+
* @param entities All entities from the configuration
|
|
369
|
+
* @param _apiType API type (unused; events are REST/Graphql agnostic)
|
|
370
|
+
* @param opts.emitEvents Top-level (global) default for emitEvents
|
|
371
|
+
*/
|
|
372
|
+
async generateDomainEvents(entities, _apiType, opts) {
|
|
373
|
+
const emittingEntities = (0, events_1.getEventEmittingEntities)(entities, opts === null || opts === void 0 ? void 0 : opts.emitEvents);
|
|
374
|
+
if (emittingEntities.length === 0) {
|
|
375
|
+
return [];
|
|
376
|
+
}
|
|
377
|
+
const templateData = {
|
|
378
|
+
emittingEntities: emittingEntities.map((entity) => ({
|
|
379
|
+
name: entity.name,
|
|
380
|
+
})),
|
|
381
|
+
generatedAt: new Date().toISOString(),
|
|
382
|
+
generatedBy: "Apso CLI",
|
|
383
|
+
};
|
|
384
|
+
const files = [];
|
|
385
|
+
const targets = [
|
|
386
|
+
{
|
|
387
|
+
template: "./events/domain-event.entity.eta",
|
|
388
|
+
path: "events/domain-event.entity.ts",
|
|
389
|
+
},
|
|
390
|
+
{
|
|
391
|
+
template: "./events/domain-event.mapper.eta",
|
|
392
|
+
path: "events/domain-event.mapper.ts",
|
|
393
|
+
},
|
|
394
|
+
{
|
|
395
|
+
template: "./events/domain-event.subscriber.eta",
|
|
396
|
+
path: "events/domain-event.subscriber.ts",
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
template: "./events/domain-event.relay.eta",
|
|
400
|
+
path: "events/domain-event.relay.ts",
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
template: "./events/domain-events.module.eta",
|
|
404
|
+
path: "events/domain-events.module.ts",
|
|
405
|
+
},
|
|
406
|
+
{
|
|
407
|
+
template: "./events/index.eta",
|
|
408
|
+
path: "events/index.ts",
|
|
409
|
+
},
|
|
410
|
+
];
|
|
411
|
+
for (const target of targets) {
|
|
412
|
+
// eslint-disable-next-line no-await-in-loop
|
|
413
|
+
const content = await this.renderTemplate(target.template, templateData);
|
|
414
|
+
files.push({ path: target.path, content });
|
|
415
|
+
}
|
|
416
|
+
return files;
|
|
417
|
+
}
|
|
358
418
|
/**
|
|
359
419
|
* Normalizes auth config by applying defaults for DB session providers
|
|
360
420
|
*/
|
package/dist/lib/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { Entity, RelationshipMap } from "./types";
|
|
2
2
|
export { parseApsorc } from "./apsorc-parser";
|
|
3
3
|
export { hasScopedEntities, getScopedEntities } from "./guards";
|
|
4
|
+
export { isEmitEventsEnabled, getEventEmittingEntities, hasEventEmittingEntities, } from "./events";
|
|
4
5
|
export { BaseGenerator, TypeScriptGenerator, createGenerator, isLanguageSupported, getSupportedLanguages, getImplementedLanguages, } from "./generators";
|
|
5
6
|
export type { TargetLanguage, GeneratedFile, ValidationResult, GeneratorConfig, LanguageGenerator, } from "./types";
|
package/dist/lib/index.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.getImplementedLanguages = exports.getSupportedLanguages = exports.isLanguageSupported = exports.createGenerator = exports.TypeScriptGenerator = exports.BaseGenerator = exports.getScopedEntities = exports.hasScopedEntities = exports.parseApsorc = void 0;
|
|
3
|
+
exports.getImplementedLanguages = exports.getSupportedLanguages = exports.isLanguageSupported = exports.createGenerator = exports.TypeScriptGenerator = exports.BaseGenerator = exports.hasEventEmittingEntities = exports.getEventEmittingEntities = exports.isEmitEventsEnabled = exports.getScopedEntities = exports.hasScopedEntities = exports.parseApsorc = void 0;
|
|
4
4
|
var apsorc_parser_1 = require("./apsorc-parser");
|
|
5
5
|
Object.defineProperty(exports, "parseApsorc", { enumerable: true, get: function () { return apsorc_parser_1.parseApsorc; } });
|
|
6
6
|
var guards_1 = require("./guards");
|
|
7
7
|
Object.defineProperty(exports, "hasScopedEntities", { enumerable: true, get: function () { return guards_1.hasScopedEntities; } });
|
|
8
8
|
Object.defineProperty(exports, "getScopedEntities", { enumerable: true, get: function () { return guards_1.getScopedEntities; } });
|
|
9
|
+
var events_1 = require("./events");
|
|
10
|
+
Object.defineProperty(exports, "isEmitEventsEnabled", { enumerable: true, get: function () { return events_1.isEmitEventsEnabled; } });
|
|
11
|
+
Object.defineProperty(exports, "getEventEmittingEntities", { enumerable: true, get: function () { return events_1.getEventEmittingEntities; } });
|
|
12
|
+
Object.defineProperty(exports, "hasEventEmittingEntities", { enumerable: true, get: function () { return events_1.hasEventEmittingEntities; } });
|
|
9
13
|
// Generator exports
|
|
10
14
|
var generators_1 = require("./generators");
|
|
11
15
|
Object.defineProperty(exports, "BaseGenerator", { enumerable: true, get: function () { return generators_1.BaseGenerator; } });
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<%~ includeFile('../header.eta') %>
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Entity,
|
|
5
|
+
PrimaryGeneratedColumn,
|
|
6
|
+
Column,
|
|
7
|
+
CreateDateColumn,
|
|
8
|
+
Index,
|
|
9
|
+
} from 'typeorm';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* DomainEvent is a durable, append-only log of domain state changes.
|
|
13
|
+
*
|
|
14
|
+
* Rows are written in the SAME database transaction as the state change that
|
|
15
|
+
* produced them (see DomainEventSubscriber), which is what makes delivery
|
|
16
|
+
* durable. This implements the standard "transactional outbox" pattern, but is
|
|
17
|
+
* surfaced with generic domain-event naming.
|
|
18
|
+
*
|
|
19
|
+
* A separate DomainEventRelay polls pending rows and delivers them. Because the
|
|
20
|
+
* stable `id` is preserved across delivery attempts, consumers can dedupe on it
|
|
21
|
+
* for at-least-once delivery semantics.
|
|
22
|
+
*/
|
|
23
|
+
@Index(['status', 'created_at'])
|
|
24
|
+
@Entity('events')
|
|
25
|
+
export class DomainEvent {
|
|
26
|
+
@PrimaryGeneratedColumn('uuid')
|
|
27
|
+
id: string;
|
|
28
|
+
|
|
29
|
+
/** Event type, e.g. "product.created". */
|
|
30
|
+
@Column({ type: 'varchar' })
|
|
31
|
+
type: string;
|
|
32
|
+
|
|
33
|
+
/** Serialized event payload. */
|
|
34
|
+
@Column({ type: 'jsonb' })
|
|
35
|
+
payload: Record<string, unknown>;
|
|
36
|
+
|
|
37
|
+
/** Delivery lifecycle: pending -> published, or failed after max attempts. */
|
|
38
|
+
@Column({ type: 'varchar', default: 'pending' })
|
|
39
|
+
status: 'pending' | 'published' | 'failed';
|
|
40
|
+
|
|
41
|
+
/** Number of delivery attempts made by the relay. */
|
|
42
|
+
@Column({ type: 'int', default: 0 })
|
|
43
|
+
attempts: number;
|
|
44
|
+
|
|
45
|
+
@CreateDateColumn({ type: 'timestamptz' })
|
|
46
|
+
created_at: Date;
|
|
47
|
+
|
|
48
|
+
/** When the event was successfully published; null until then. */
|
|
49
|
+
@Column({ type: 'timestamptz', nullable: true })
|
|
50
|
+
publishedAt: Date | null;
|
|
51
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<%~ includeFile('../header.eta') %>
|
|
2
|
+
|
|
3
|
+
import { Injectable } from '@nestjs/common';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Action that produced a domain event.
|
|
7
|
+
*/
|
|
8
|
+
export type DomainEventAction = 'created' | 'updated' | 'removed';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* DI token for the DomainEventMapper.
|
|
12
|
+
*
|
|
13
|
+
* The generated DomainEventsModule binds this token to DefaultDomainEventMapper.
|
|
14
|
+
* Your application can override it by providing its own implementation:
|
|
15
|
+
*
|
|
16
|
+
* { provide: DOMAIN_EVENT_MAPPER, useClass: MyDomainEventMapper }
|
|
17
|
+
*/
|
|
18
|
+
export const DOMAIN_EVENT_MAPPER = 'DOMAIN_EVENT_MAPPER';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* DomainEventMapper is the extension point that keeps application semantics
|
|
22
|
+
* (event-type taxonomy, payload shape) OUT of the generated code. Provide your
|
|
23
|
+
* own implementation under DOMAIN_EVENT_MAPPER to customize.
|
|
24
|
+
*/
|
|
25
|
+
export interface DomainEventMapper {
|
|
26
|
+
/**
|
|
27
|
+
* Maps an entity name + action to an event type string, e.g.
|
|
28
|
+
* ("Product", "created") -> "product.created".
|
|
29
|
+
*/
|
|
30
|
+
eventType(entityName: string, action: DomainEventAction): string;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Builds the serializable payload for an event from the changed entity.
|
|
34
|
+
*/
|
|
35
|
+
toPayload(
|
|
36
|
+
entity: unknown,
|
|
37
|
+
action: DomainEventAction
|
|
38
|
+
): Record<string, unknown>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Default mapper. Uses an `entity.action` type taxonomy (entity name
|
|
43
|
+
* lower-camel-cased) and returns the entity as-is (shallow) as the payload.
|
|
44
|
+
*
|
|
45
|
+
* Override by providing your own DomainEventMapper under DOMAIN_EVENT_MAPPER.
|
|
46
|
+
*/
|
|
47
|
+
@Injectable()
|
|
48
|
+
export class DefaultDomainEventMapper implements DomainEventMapper {
|
|
49
|
+
eventType(entityName: string, action: DomainEventAction): string {
|
|
50
|
+
const normalized = entityName
|
|
51
|
+
? entityName.charAt(0).toLowerCase() + entityName.slice(1)
|
|
52
|
+
: entityName;
|
|
53
|
+
return `${normalized}.${action}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
toPayload(
|
|
57
|
+
entity: unknown,
|
|
58
|
+
_action: DomainEventAction
|
|
59
|
+
): Record<string, unknown> {
|
|
60
|
+
return { ...(entity as Record<string, unknown>) };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
<%~ includeFile('../header.eta') %>
|
|
2
|
+
|
|
3
|
+
import { Injectable, Logger } from '@nestjs/common';
|
|
4
|
+
import { InjectRepository } from '@nestjs/typeorm';
|
|
5
|
+
import { Repository } from 'typeorm';
|
|
6
|
+
import { DomainEvent } from './domain-event.entity';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Maximum number of delivery attempts before an event is marked 'failed'.
|
|
10
|
+
*/
|
|
11
|
+
const MAX_ATTEMPTS = 5;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* DomainEventRelay drains the durable `events` table and delivers pending
|
|
15
|
+
* events. It is the consumer side of the transactional-outbox pattern: the
|
|
16
|
+
* subscriber writes events transactionally, the relay publishes them
|
|
17
|
+
* asynchronously with at-least-once semantics.
|
|
18
|
+
*
|
|
19
|
+
* DELIVERY is the extension point: override `publish()` (see below) to send
|
|
20
|
+
* events anywhere — webhooks, Kafka, SNS, an internal event bus, etc. The
|
|
21
|
+
* generated code intentionally does NOT implement any specific transport.
|
|
22
|
+
*/
|
|
23
|
+
@Injectable()
|
|
24
|
+
export class DomainEventRelay {
|
|
25
|
+
private readonly logger = new Logger(DomainEventRelay.name);
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
@InjectRepository(DomainEvent)
|
|
29
|
+
private readonly events: Repository<DomainEvent>
|
|
30
|
+
) {}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Schedule this from your app, e.g. with @nestjs/schedule:
|
|
34
|
+
*
|
|
35
|
+
* // import { Interval } from '@nestjs/schedule';
|
|
36
|
+
* // @Interval(5000)
|
|
37
|
+
* // async tick() { await this.relay.processPending(); }
|
|
38
|
+
*
|
|
39
|
+
* (@nestjs/schedule is intentionally NOT imported here so no dependency is
|
|
40
|
+
* forced on your project.)
|
|
41
|
+
*/
|
|
42
|
+
async processPending(limit = 50): Promise<void> {
|
|
43
|
+
const pending = await this.events.find({
|
|
44
|
+
where: { status: 'pending' },
|
|
45
|
+
order: { created_at: 'ASC' },
|
|
46
|
+
take: limit,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
for (const event of pending) {
|
|
50
|
+
try {
|
|
51
|
+
// eslint-disable-next-line no-await-in-loop
|
|
52
|
+
await this.publish(event);
|
|
53
|
+
event.status = 'published';
|
|
54
|
+
event.publishedAt = new Date();
|
|
55
|
+
// eslint-disable-next-line no-await-in-loop
|
|
56
|
+
await this.events.save(event);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
event.attempts += 1;
|
|
59
|
+
if (event.attempts >= MAX_ATTEMPTS) {
|
|
60
|
+
event.status = 'failed';
|
|
61
|
+
this.logger.error(
|
|
62
|
+
`DomainEvent ${event.id} (${event.type}) failed after ${event.attempts} attempts`,
|
|
63
|
+
error instanceof Error ? error.stack : undefined
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
// eslint-disable-next-line no-await-in-loop
|
|
67
|
+
await this.events.save(event);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* EXTENSION POINT — deliver a single domain event.
|
|
74
|
+
*
|
|
75
|
+
* Override (subclass or re-provide) this method to actually publish events to
|
|
76
|
+
* your transport of choice (webhooks, Kafka, SNS, internal bus, ...). On
|
|
77
|
+
* success the relay marks the event published; on a thrown error it retries
|
|
78
|
+
* up to MAX_ATTEMPTS and then marks it failed.
|
|
79
|
+
*/
|
|
80
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
81
|
+
async publish(event: DomainEvent): Promise<void> {
|
|
82
|
+
throw new Error(
|
|
83
|
+
'DomainEventRelay.publish() not implemented — override to deliver events'
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
<%~ includeFile('../header.eta') %>
|
|
2
|
+
|
|
3
|
+
import { Inject, Injectable, Optional } from '@nestjs/common';
|
|
4
|
+
import {
|
|
5
|
+
DataSource,
|
|
6
|
+
EntitySubscriberInterface,
|
|
7
|
+
EventSubscriber,
|
|
8
|
+
InsertEvent,
|
|
9
|
+
UpdateEvent,
|
|
10
|
+
RemoveEvent,
|
|
11
|
+
} from 'typeorm';
|
|
12
|
+
import { DomainEvent } from './domain-event.entity';
|
|
13
|
+
import {
|
|
14
|
+
DOMAIN_EVENT_MAPPER,
|
|
15
|
+
DomainEventAction,
|
|
16
|
+
DomainEventMapper,
|
|
17
|
+
} from './domain-event.mapper';
|
|
18
|
+
<% it.emittingEntities.forEach((entity) => { %>
|
|
19
|
+
import { <%= entity.name %> } from '../<%= entity.name %>/<%= entity.name %>.entity';
|
|
20
|
+
<% }) %>
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Entity classes that have opted in to domain-event emission. Only state
|
|
24
|
+
* changes to these entities produce DomainEvent rows; everything else is
|
|
25
|
+
* skipped.
|
|
26
|
+
*/
|
|
27
|
+
const EMITTING_ENTITIES: Function[] = [
|
|
28
|
+
<% it.emittingEntities.forEach((entity) => { %>
|
|
29
|
+
<%= entity.name %>,
|
|
30
|
+
<% }) %>
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* DomainEventSubscriber writes a DomainEvent row for every insert/update/remove
|
|
35
|
+
* of an opted-in entity.
|
|
36
|
+
*
|
|
37
|
+
* DURABILITY: each row is written via `event.manager` (the EntityManager of the
|
|
38
|
+
* in-flight transaction), so the event is committed atomically WITH the state
|
|
39
|
+
* change. This is the durability lever behind the transactional-outbox pattern.
|
|
40
|
+
*
|
|
41
|
+
* RECURSION GUARD: we never emit events for DomainEvent itself, otherwise each
|
|
42
|
+
* emitted row would itself trigger another emission. Any other bookkeeping
|
|
43
|
+
* tables you do not want logged should likewise be excluded from
|
|
44
|
+
* EMITTING_ENTITIES (this scoping handles that — only opted-in entities emit).
|
|
45
|
+
*/
|
|
46
|
+
@Injectable()
|
|
47
|
+
@EventSubscriber()
|
|
48
|
+
export class DomainEventSubscriber implements EntitySubscriberInterface {
|
|
49
|
+
constructor(
|
|
50
|
+
@Inject(DOMAIN_EVENT_MAPPER) private readonly mapper: DomainEventMapper,
|
|
51
|
+
// MULTI-DATASOURCE: Nest auto-registers @EventSubscriber() providers on the
|
|
52
|
+
// DEFAULT DataSource. We ALSO push ourselves onto any injected DataSource
|
|
53
|
+
// here so registration is robust for named/non-default DataSources too. For
|
|
54
|
+
// multiple DataSources, register DomainEventsModule per DataSource.
|
|
55
|
+
@Optional() dataSource?: DataSource
|
|
56
|
+
) {
|
|
57
|
+
dataSource?.subscribers?.push(this);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private isEmitting(target: Function | string): boolean {
|
|
61
|
+
// RECURSION GUARD: never emit for DomainEvent itself.
|
|
62
|
+
if (target === DomainEvent || target === 'DomainEvent') {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
return EMITTING_ENTITIES.some(
|
|
66
|
+
(cls) => target === cls || target === cls.name
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private async emit(
|
|
71
|
+
manager: InsertEvent<unknown>['manager'],
|
|
72
|
+
entityClass: Function,
|
|
73
|
+
entity: unknown,
|
|
74
|
+
action: DomainEventAction
|
|
75
|
+
): Promise<void> {
|
|
76
|
+
const entityName = entityClass.name;
|
|
77
|
+
const repo = manager.getRepository(DomainEvent);
|
|
78
|
+
// event.manager keeps us inside the active transaction.
|
|
79
|
+
await repo.save(
|
|
80
|
+
repo.create({
|
|
81
|
+
type: this.mapper.eventType(entityName, action),
|
|
82
|
+
payload: this.mapper.toPayload(entity, action),
|
|
83
|
+
status: 'pending',
|
|
84
|
+
attempts: 0,
|
|
85
|
+
})
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async afterInsert(event: InsertEvent<unknown>): Promise<void> {
|
|
90
|
+
if (!this.isEmitting(event.metadata.target)) return;
|
|
91
|
+
await this.emit(
|
|
92
|
+
event.manager,
|
|
93
|
+
event.metadata.target as Function,
|
|
94
|
+
event.entity,
|
|
95
|
+
'created'
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async afterUpdate(event: UpdateEvent<unknown>): Promise<void> {
|
|
100
|
+
if (!this.isEmitting(event.metadata.target)) return;
|
|
101
|
+
await this.emit(
|
|
102
|
+
event.manager,
|
|
103
|
+
event.metadata.target as Function,
|
|
104
|
+
event.entity ?? event.databaseEntity,
|
|
105
|
+
'updated'
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async afterRemove(event: RemoveEvent<unknown>): Promise<void> {
|
|
110
|
+
if (!this.isEmitting(event.metadata.target)) return;
|
|
111
|
+
await this.emit(
|
|
112
|
+
event.manager,
|
|
113
|
+
event.metadata.target as Function,
|
|
114
|
+
event.entity ?? event.databaseEntity,
|
|
115
|
+
'removed'
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<%~ includeFile('../header.eta') %>
|
|
2
|
+
|
|
3
|
+
import { Global, Module } from '@nestjs/common';
|
|
4
|
+
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
5
|
+
import { DomainEvent } from './domain-event.entity';
|
|
6
|
+
import { DomainEventSubscriber } from './domain-event.subscriber';
|
|
7
|
+
import { DomainEventRelay } from './domain-event.relay';
|
|
8
|
+
import {
|
|
9
|
+
DOMAIN_EVENT_MAPPER,
|
|
10
|
+
DefaultDomainEventMapper,
|
|
11
|
+
} from './domain-event.mapper';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* DomainEventsModule wires the durable domain-event spine (transactional-outbox
|
|
15
|
+
* pattern, surfaced as generic domain events).
|
|
16
|
+
*
|
|
17
|
+
* - DomainEventSubscriber writes events in-transaction with state changes.
|
|
18
|
+
* - DomainEventRelay drains and delivers pending events (override
|
|
19
|
+
* DomainEventRelay.publish()).
|
|
20
|
+
* - DOMAIN_EVENT_MAPPER controls event-type/payload semantics; override by
|
|
21
|
+
* re-providing the token with your own DomainEventMapper.
|
|
22
|
+
*
|
|
23
|
+
* MULTI-DATASOURCE: @EventSubscriber() auto-registers on the default DataSource;
|
|
24
|
+
* for additional named DataSources, register this module (or the subscriber)
|
|
25
|
+
* per DataSource.
|
|
26
|
+
*/
|
|
27
|
+
@Global()
|
|
28
|
+
@Module({
|
|
29
|
+
imports: [TypeOrmModule.forFeature([DomainEvent])],
|
|
30
|
+
providers: [
|
|
31
|
+
DomainEventSubscriber,
|
|
32
|
+
DomainEventRelay,
|
|
33
|
+
{
|
|
34
|
+
provide: DOMAIN_EVENT_MAPPER,
|
|
35
|
+
useClass: DefaultDomainEventMapper,
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
exports: [DomainEventRelay, DOMAIN_EVENT_MAPPER],
|
|
39
|
+
})
|
|
40
|
+
export class DomainEventsModule {}
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
<% it.entities.forEach((entity) => { %>
|
|
4
4
|
import {<%= entity.name %>Module} from './<%= entity.name %>/<%= entity.name %>.module'
|
|
5
5
|
<%}) %>
|
|
6
|
+
<% if (it.emitDomainEvents) { %>
|
|
7
|
+
import { DomainEventsModule } from './events'
|
|
8
|
+
<% } %>
|
|
6
9
|
import { JSONResolver } from 'graphql-scalars';
|
|
7
10
|
|
|
8
11
|
<% const scalars = [] %>
|
|
@@ -23,4 +26,7 @@ export default [
|
|
|
23
26
|
<% it.entities.forEach((entity) => { %>
|
|
24
27
|
<%= entity.name %>Module,
|
|
25
28
|
<%}) %>
|
|
29
|
+
<% if (it.emitDomainEvents) { %>
|
|
30
|
+
DomainEventsModule,
|
|
31
|
+
<% } %>
|
|
26
32
|
]
|
|
@@ -3,9 +3,15 @@
|
|
|
3
3
|
<% it.entities.forEach((entity) => { %>
|
|
4
4
|
import {<%= entity.name %>Module} from './<%= entity.name %>/<%= entity.name %>.module'
|
|
5
5
|
<%}) %>
|
|
6
|
+
<% if (it.emitDomainEvents) { %>
|
|
7
|
+
import { DomainEventsModule } from './events'
|
|
8
|
+
<% } %>
|
|
6
9
|
|
|
7
10
|
export default [
|
|
8
11
|
<% it.entities.forEach((entity) => { %>
|
|
9
12
|
<%= entity.name %>Module,
|
|
10
13
|
<%}) %>
|
|
14
|
+
<% if (it.emitDomainEvents) { %>
|
|
15
|
+
DomainEventsModule,
|
|
16
|
+
<% } %>
|
|
11
17
|
]
|
|
@@ -45,5 +45,15 @@ export interface Entity {
|
|
|
45
45
|
* Optional configuration for how scope enforcement should behave.
|
|
46
46
|
*/
|
|
47
47
|
scopeOptions?: ScopeOptions;
|
|
48
|
+
/**
|
|
49
|
+
* When true, state changes to this entity (insert/update/remove) emit a
|
|
50
|
+
* durable DomainEvent row written in the SAME database transaction as the
|
|
51
|
+
* change (transactional-outbox pattern, surfaced as generic "domain events").
|
|
52
|
+
*
|
|
53
|
+
* Resolution: the effective value is `entity.emitEvents ?? <global emitEvents> ?? false`.
|
|
54
|
+
* This means a top-level `emitEvents: true` enables it for every entity, while
|
|
55
|
+
* an individual entity can opt out with `emitEvents: false`.
|
|
56
|
+
*/
|
|
57
|
+
emitEvents?: boolean;
|
|
48
58
|
associations?: Association[];
|
|
49
59
|
}
|
|
@@ -104,6 +104,11 @@ export interface GeneratorConfig {
|
|
|
104
104
|
* Authentication configuration (optional)
|
|
105
105
|
*/
|
|
106
106
|
auth?: AuthConfig;
|
|
107
|
+
/**
|
|
108
|
+
* Top-level default for the opt-in DomainEvent ("emitEvents") feature.
|
|
109
|
+
* Effective per-entity value is `entity.emitEvents ?? emitEvents ?? false`.
|
|
110
|
+
*/
|
|
111
|
+
emitEvents?: boolean;
|
|
107
112
|
/**
|
|
108
113
|
* Language-specific configuration options
|
|
109
114
|
*/
|
|
@@ -229,7 +234,9 @@ export interface LanguageGenerator {
|
|
|
229
234
|
/**
|
|
230
235
|
* Generate the index/main module file that ties everything together
|
|
231
236
|
*/
|
|
232
|
-
generateIndexModule(entities: Entity[], apiType: string
|
|
237
|
+
generateIndexModule(entities: Entity[], apiType: string, opts?: {
|
|
238
|
+
emitEvents?: boolean;
|
|
239
|
+
}): Promise<GeneratedFile[]>;
|
|
233
240
|
/**
|
|
234
241
|
* Generate guard files (auth, scope, etc.)
|
|
235
242
|
*/
|
|
@@ -238,6 +245,13 @@ export interface LanguageGenerator {
|
|
|
238
245
|
* Generate shared query utility files (filter, sort, pagination)
|
|
239
246
|
*/
|
|
240
247
|
generateQueryUtils(entities: Entity[], apiType: string): Promise<GeneratedFile[]>;
|
|
248
|
+
/**
|
|
249
|
+
* Generate the durable DomainEvent spine for entities that opt in to
|
|
250
|
+
* `emitEvents`. Returns [] when no entity is opted in.
|
|
251
|
+
*/
|
|
252
|
+
generateDomainEvents(entities: Entity[], apiType?: string, opts?: {
|
|
253
|
+
emitEvents?: boolean;
|
|
254
|
+
}): Promise<GeneratedFile[]>;
|
|
241
255
|
}
|
|
242
256
|
/**
|
|
243
257
|
* Factory function type for creating language generators
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@apso/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@apso/cli",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.17.0",
|
|
10
10
|
"license": "Apache-2.0",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@electric-sql/pglite": "^0.2.17",
|
package/oclif.manifest.json
CHANGED