@pattern-stack/codegen 0.2.0 → 0.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 +9 -4
- package/dist/src/cli/index.js +136 -128
- package/dist/src/cli/index.js.map +1 -1
- package/dist/src/index.d.ts +16 -0
- package/dist/src/index.js +25 -0
- package/dist/src/index.js.map +1 -1
- package/package.json +10 -1
- package/templates/entity/new/backend/application/commands/create.ejs.t +38 -1
- package/templates/entity/new/backend/application/commands/delete.ejs.t +41 -1
- package/templates/entity/new/backend/application/commands/update.ejs.t +42 -1
- package/templates/entity/new/backend/database/repository.ejs.t +33 -3
- package/templates/entity/new/backend/domain/repository-interface.ejs.t +6 -3
- package/templates/entity/new/backend/modules/core/module.ejs.t +6 -0
- package/templates/entity/new/backend/presentation/controller.ejs.t +32 -10
- package/templates/entity/new/clean-lite-ps/controller.ejs.t +72 -11
- package/templates/entity/new/clean-lite-ps/entity.ejs.t +16 -2
- package/templates/entity/new/clean-lite-ps/index.ejs.t +1 -1
- package/templates/entity/new/clean-lite-ps/module.ejs.t +45 -2
- package/templates/entity/new/clean-lite-ps/prompt-extension.js +459 -98
- package/templates/entity/new/clean-lite-ps/repository.ejs.t +57 -4
- package/templates/entity/new/clean-lite-ps/search-controller.ejs.t +50 -0
- package/templates/entity/new/clean-lite-ps/service.ejs.t +98 -1
- package/templates/entity/new/clean-lite-ps/use-cases/create.ejs.t +150 -0
- package/templates/entity/new/clean-lite-ps/use-cases/delete.ejs.t +70 -0
- package/templates/entity/new/clean-lite-ps/use-cases/find-by-id-with-fields.ejs.t +19 -0
- package/templates/entity/new/clean-lite-ps/use-cases/find-by-id.ejs.t +7 -3
- package/templates/entity/new/clean-lite-ps/use-cases/list-with-fields.ejs.t +17 -0
- package/templates/entity/new/clean-lite-ps/use-cases/search.ejs.t +63 -0
- package/templates/entity/new/clean-lite-ps/use-cases/update.ejs.t +153 -0
- package/templates/entity/new/prompt.js +284 -41
- package/templates/relationship/new/entity.ejs.t +2 -2
- package/templates/relationship/new/prompt.js +3 -7
- package/templates/relationship/new/service.ejs.t +1 -1
- package/templates/subsystem/bridge/generated-keep.ejs.t +4 -0
- package/templates/subsystem/bridge/prompt.js +36 -0
- package/templates/subsystem/bridge-config/codegen-config-bridge-block.ejs.t +20 -0
- package/templates/subsystem/bridge-config/prompt.js +20 -0
- package/templates/subsystem/events/domain-events.schema.ejs.t +81 -0
- package/templates/subsystem/events/generated-keep.ejs.t +4 -0
- package/templates/subsystem/events/prompt.js +39 -0
- package/templates/subsystem/events-config/codegen-config-events-block.ejs.t +26 -0
- package/templates/subsystem/events-config/prompt.js +20 -0
- package/templates/subsystem/jobs/job-orchestration.schema.ejs.t +221 -0
- package/templates/subsystem/jobs/main-hook.ejs.t +11 -0
- package/templates/subsystem/jobs/prompt.js +40 -0
- package/templates/subsystem/jobs/worker.ejs.t +82 -0
- package/templates/subsystem/jobs-config/codegen-config-jobs-block.ejs.t +55 -0
- package/templates/subsystem/jobs-config/prompt.js +20 -0
- package/templates/subsystem/sync/prompt.js +43 -0
- package/templates/subsystem/sync/sync-audit.schema.ejs.t +195 -0
- package/templates/subsystem/sync-config/codegen-config-sync-block.ejs.t +29 -0
- package/templates/subsystem/sync-config/prompt.js +22 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
---
|
|
2
|
+
to: "<%= typeof clpOutputPaths !== 'undefined' ? clpOutputPaths.updateUseCase : null %>"
|
|
3
|
+
skip_if: "<%= typeof clpOutputPaths === 'undefined' || !clpOutputPaths.updateUseCase %>"
|
|
4
|
+
force: true
|
|
5
|
+
---
|
|
6
|
+
<% if (eavEnabled) { -%>
|
|
7
|
+
import { Injectable, Inject } from '@nestjs/common';
|
|
8
|
+
import { DRIZZLE } from '<%= drizzleTokenImport %>';
|
|
9
|
+
import type { DrizzleClient } from '<%= drizzleTypeImport %>';
|
|
10
|
+
<% if (hasEmits && updateEventType) { -%>
|
|
11
|
+
import { TYPED_EVENT_BUS, TypedEventBus } from '<%= eventsTokenImport %>';
|
|
12
|
+
<% } -%>
|
|
13
|
+
import { FieldValueService } from '../../field_values/field_value.service';
|
|
14
|
+
import { <%= classNames.service %> } from '../<%= entityName %>.service';
|
|
15
|
+
import type { <%= classNames.updateDto %> } from '../dto/update-<%= entityName %>.dto';
|
|
16
|
+
import type { <%= classNames.entity %> } from '../<%= entityName %>.entity';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* EAV compound-write use case (ADR-13).
|
|
20
|
+
*
|
|
21
|
+
* Mirrors CreateUseCase: splits `{ fields, ...core }`, updates core columns
|
|
22
|
+
* via <%= classNames.service %> and upserts dynamic fields via
|
|
23
|
+
* FieldValueService.upsertFieldsTransactional in a single transaction.
|
|
24
|
+
* Returns null if the entity was not found.
|
|
25
|
+
<% if (hasEmits && updateEventType) { -%>
|
|
26
|
+
*
|
|
27
|
+
* EXTENSION POINT (EVT-7): verify payload mapping against
|
|
28
|
+
* events/<%= updateEventType.type %>.yaml before shipping.
|
|
29
|
+
<% } -%>
|
|
30
|
+
*/
|
|
31
|
+
@Injectable()
|
|
32
|
+
export class <%= classNames.updateUseCase %> {
|
|
33
|
+
constructor(
|
|
34
|
+
private readonly <%= entityNamePlural %>: <%= classNames.service %>,
|
|
35
|
+
private readonly fields: FieldValueService,
|
|
36
|
+
@Inject(DRIZZLE) private readonly db: DrizzleClient,
|
|
37
|
+
<% if (hasEmits && updateEventType) { -%>
|
|
38
|
+
@Inject(TYPED_EVENT_BUS) private readonly typedEvents: TypedEventBus,
|
|
39
|
+
<% } -%>
|
|
40
|
+
) {}
|
|
41
|
+
|
|
42
|
+
async execute(
|
|
43
|
+
id: string,
|
|
44
|
+
dto: <%= classNames.updateDto %> & { fields?: Record<string, unknown> },
|
|
45
|
+
<%= hasEmits && updateEventType ? 'opts' : '_opts' %>?: { actor?: { tenantId?: string | null; userId?: string } },
|
|
46
|
+
): Promise<<%= classNames.entity %> | null> {
|
|
47
|
+
return this.db.transaction(async (tx) => {
|
|
48
|
+
const { fields, ...core } = dto;
|
|
49
|
+
const entity = await this.<%= entityNamePlural %>.update(id, core as <%= classNames.updateDto %>, tx);
|
|
50
|
+
if (!entity) return null;
|
|
51
|
+
if (fields && Object.keys(fields).length > 0) {
|
|
52
|
+
await this.fields.upsertFieldsTransactional(
|
|
53
|
+
'<%= entityName %>',
|
|
54
|
+
entity.id,
|
|
55
|
+
entity.userId,
|
|
56
|
+
fields,
|
|
57
|
+
tx,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
<% if (hasEmits && updateEventType) { -%>
|
|
61
|
+
// TODO: verify payload mapping against events/<%= updateEventType.type %>.yaml
|
|
62
|
+
await this.typedEvents.publish(
|
|
63
|
+
'<%= updateEventType.type %>',
|
|
64
|
+
entity.id,
|
|
65
|
+
{
|
|
66
|
+
<% updateEventType.payloadMap.forEach((p) => { -%>
|
|
67
|
+
<%= p.camelKey %>: <%- p.expression %>,<% if (p.todo) { %> // TODO: <%= p.todo %><% } %>
|
|
68
|
+
|
|
69
|
+
<% }) -%>
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
tx,
|
|
73
|
+
metadata: opts?.actor
|
|
74
|
+
? { tenantId: opts.actor.tenantId, userId: opts.actor.userId }
|
|
75
|
+
: undefined,
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
<% } -%>
|
|
79
|
+
return entity;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
<% } else { -%>
|
|
84
|
+
<% if (hasEmits && updateEventType) { -%>
|
|
85
|
+
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
|
86
|
+
import { DRIZZLE } from '<%= drizzleTokenImport %>';
|
|
87
|
+
import type { DrizzleClient } from '<%= drizzleTypeImport %>';
|
|
88
|
+
import { TYPED_EVENT_BUS, TypedEventBus } from '<%= eventsTokenImport %>';
|
|
89
|
+
import { <%= classNames.service %> } from '../<%= entityName %>.service';
|
|
90
|
+
import type { <%= classNames.updateDto %> } from '../dto/update-<%= entityName %>.dto';
|
|
91
|
+
import type { <%= classNames.entity %> } from '../<%= entityName %>.entity';
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* EXTENSION POINT (EVT-7): verify payload mapping against
|
|
95
|
+
* events/<%= updateEventType.type %>.yaml before shipping.
|
|
96
|
+
*/
|
|
97
|
+
@Injectable()
|
|
98
|
+
export class <%= classNames.updateUseCase %> {
|
|
99
|
+
constructor(
|
|
100
|
+
private readonly service: <%= classNames.service %>,
|
|
101
|
+
@Inject(DRIZZLE) private readonly db: DrizzleClient,
|
|
102
|
+
@Inject(TYPED_EVENT_BUS) private readonly typedEvents: TypedEventBus,
|
|
103
|
+
) {}
|
|
104
|
+
|
|
105
|
+
async execute(
|
|
106
|
+
id: string,
|
|
107
|
+
dto: <%= classNames.updateDto %>,
|
|
108
|
+
opts?: { actor?: { tenantId?: string | null; userId?: string } },
|
|
109
|
+
): Promise<<%= classNames.entity %> | null> {
|
|
110
|
+
return this.db.transaction(async (tx) => {
|
|
111
|
+
const entity = await this.service.update(id, dto, tx);
|
|
112
|
+
if (!entity) return null;
|
|
113
|
+
// TODO: verify payload mapping against events/<%= updateEventType.type %>.yaml
|
|
114
|
+
await this.typedEvents.publish(
|
|
115
|
+
'<%= updateEventType.type %>',
|
|
116
|
+
entity.id,
|
|
117
|
+
{
|
|
118
|
+
<% updateEventType.payloadMap.forEach((p) => { -%>
|
|
119
|
+
<%= p.camelKey %>: <%- p.expression %>,<% if (p.todo) { %> // TODO: <%= p.todo %><% } %>
|
|
120
|
+
|
|
121
|
+
<% }) -%>
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
tx,
|
|
125
|
+
metadata: opts?.actor
|
|
126
|
+
? { tenantId: opts.actor.tenantId, userId: opts.actor.userId }
|
|
127
|
+
: undefined,
|
|
128
|
+
},
|
|
129
|
+
);
|
|
130
|
+
return entity;
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
<% } else { -%>
|
|
135
|
+
import { Injectable } from '@nestjs/common';
|
|
136
|
+
import { <%= classNames.service %> } from '../<%= entityName %>.service';
|
|
137
|
+
import type { <%= classNames.updateDto %> } from '../dto/update-<%= entityName %>.dto';
|
|
138
|
+
import type { <%= classNames.entity %> } from '../<%= entityName %>.entity';
|
|
139
|
+
|
|
140
|
+
@Injectable()
|
|
141
|
+
export class <%= classNames.updateUseCase %> {
|
|
142
|
+
constructor(private readonly service: <%= classNames.service %>) {}
|
|
143
|
+
|
|
144
|
+
async execute(
|
|
145
|
+
id: string,
|
|
146
|
+
dto: <%= classNames.updateDto %>,
|
|
147
|
+
_opts?: { actor?: { tenantId?: string | null; userId?: string } },
|
|
148
|
+
): Promise<<%= classNames.entity %> | null> {
|
|
149
|
+
return this.service.update(id, dto);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
<% } -%>
|
|
153
|
+
<% } -%>
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import fs from "node:fs";
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import yaml from "yaml";
|
|
10
|
+
import pluralizePkg from "pluralize";
|
|
10
11
|
import {
|
|
11
12
|
BACKEND_LAYERS,
|
|
12
13
|
BASE_PATHS,
|
|
@@ -220,6 +221,56 @@ function resolveBehaviors(behaviorConfigs) {
|
|
|
220
221
|
};
|
|
221
222
|
}
|
|
222
223
|
|
|
224
|
+
|
|
225
|
+
// ============================================================================
|
|
226
|
+
// Patterns — subprocess-local registry load (PATTERN-5)
|
|
227
|
+
// ============================================================================
|
|
228
|
+
//
|
|
229
|
+
// The Hygen subprocess has no shared memory with the CLI process, so the
|
|
230
|
+
// pattern registry is rebuilt here from scratch. Library patterns register
|
|
231
|
+
// themselves as a side effect of importing the barrel; app-defined patterns
|
|
232
|
+
// are loaded from `codegen.config.yaml patterns:` globs (default
|
|
233
|
+
// `src/patterns/*.pattern.ts`). Both loads are deterministic and
|
|
234
|
+
// side-effect-free — the registry determinism test in
|
|
235
|
+
// `src/__tests__/patterns/registry.test.ts` pins down that the CLI and the
|
|
236
|
+
// subprocess produce identical sorted results for the same file set.
|
|
237
|
+
|
|
238
|
+
let _patternsLoadPromise = null;
|
|
239
|
+
|
|
240
|
+
async function ensurePatternsRegistryLoaded() {
|
|
241
|
+
if (!_patternsLoadPromise) {
|
|
242
|
+
_patternsLoadPromise = (async () => {
|
|
243
|
+
// Side-effect import: pre-registers the five library patterns.
|
|
244
|
+
await import('../../../src/patterns/library/index.js');
|
|
245
|
+
const { loadAppPatterns } = await import('../../../src/patterns/registry.js');
|
|
246
|
+
|
|
247
|
+
// Read the `patterns:` manifest from codegen.config.yaml. Defaults
|
|
248
|
+
// to a single sensible glob when the key is absent — matches the
|
|
249
|
+
// ADR-031 default discovery shape.
|
|
250
|
+
const configPath = path.resolve(process.cwd(), 'codegen.config.yaml');
|
|
251
|
+
let manifest = ['src/patterns/*.pattern.ts'];
|
|
252
|
+
if (fs.existsSync(configPath)) {
|
|
253
|
+
try {
|
|
254
|
+
const parsed = yaml.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
255
|
+
if (Array.isArray(parsed?.patterns)) {
|
|
256
|
+
manifest = parsed.patterns;
|
|
257
|
+
}
|
|
258
|
+
} catch {
|
|
259
|
+
// fall through with the default manifest; a malformed
|
|
260
|
+
// codegen.config.yaml is already surfaced by the CLI's config
|
|
261
|
+
// loader elsewhere.
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const result = await loadAppPatterns(manifest, process.cwd());
|
|
265
|
+
for (const err of result.errors) {
|
|
266
|
+
// eslint-disable-next-line no-console
|
|
267
|
+
console.warn(`[codegen] ${err}`);
|
|
268
|
+
}
|
|
269
|
+
})();
|
|
270
|
+
}
|
|
271
|
+
return _patternsLoadPromise;
|
|
272
|
+
}
|
|
273
|
+
|
|
223
274
|
export default {
|
|
224
275
|
prompt: async ({ args }) => {
|
|
225
276
|
const yamlPath = args.yaml;
|
|
@@ -255,22 +306,18 @@ export default {
|
|
|
255
306
|
const queriesBlock = definition.queries || null;
|
|
256
307
|
const syncBlock = definition.sync || null;
|
|
257
308
|
const eventsBlock = definition.events || null;
|
|
309
|
+
// EVT-7: emits is semantically 3-valued — undefined (fallback path),
|
|
310
|
+
// [] (explicit opt-out), or string[] (typed emission). Preserve the
|
|
311
|
+
// undefined/null-vs-empty distinction by refusing the || null shortcut.
|
|
312
|
+
const emitsBlock = Array.isArray(definition.emits)
|
|
313
|
+
? definition.emits
|
|
314
|
+
: null;
|
|
258
315
|
|
|
259
316
|
// Helper functions
|
|
260
317
|
const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
261
318
|
const camelCase = (s) => s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
262
319
|
const pascalCase = (s) => capitalize(camelCase(s));
|
|
263
|
-
const pluralize = (s) =>
|
|
264
|
-
if (s.endsWith("y")) return s.slice(0, -1) + "ies";
|
|
265
|
-
if (
|
|
266
|
-
s.endsWith("s") ||
|
|
267
|
-
s.endsWith("x") ||
|
|
268
|
-
s.endsWith("ch") ||
|
|
269
|
-
s.endsWith("sh")
|
|
270
|
-
)
|
|
271
|
-
return s + "es";
|
|
272
|
-
return s + "s";
|
|
273
|
-
};
|
|
320
|
+
const pluralize = (s) => pluralizePkg.plural(s);
|
|
274
321
|
|
|
275
322
|
// ============================================================================
|
|
276
323
|
// UI Metadata Inference Functions
|
|
@@ -582,7 +629,9 @@ export default {
|
|
|
582
629
|
const zodTypes = {
|
|
583
630
|
string: "z.string()",
|
|
584
631
|
integer: "z.number().int()",
|
|
585
|
-
|
|
632
|
+
// Drizzle maps PG `numeric` to JS string — z.coerce.string() avoids
|
|
633
|
+
// silent precision loss. Aligned with clean-lite-ps (PR #42). See #43.
|
|
634
|
+
decimal: "z.coerce.string()",
|
|
586
635
|
boolean: "z.boolean()",
|
|
587
636
|
uuid: "z.string().uuid()",
|
|
588
637
|
date: "z.coerce.date()",
|
|
@@ -962,29 +1011,6 @@ export default {
|
|
|
962
1011
|
const isCleanLitePs = architectureTarget === 'clean-lite-ps';
|
|
963
1012
|
const frontendEnabled = generateConfig.frontend === true;
|
|
964
1013
|
|
|
965
|
-
// ============================================================================
|
|
966
|
-
// v2: Family
|
|
967
|
-
// ============================================================================
|
|
968
|
-
|
|
969
|
-
const FAMILY_REPOSITORY_MAP = {
|
|
970
|
-
'synced': 'SyncedEntityRepository',
|
|
971
|
-
'activity': 'ActivityEntityRepository',
|
|
972
|
-
'knowledge': 'KnowledgeEntityRepository',
|
|
973
|
-
'metadata': 'MetadataEntityRepository',
|
|
974
|
-
};
|
|
975
|
-
|
|
976
|
-
const FAMILY_SERVICE_MAP = {
|
|
977
|
-
'synced': 'SyncedEntityService',
|
|
978
|
-
'activity': 'ActivityEntityService',
|
|
979
|
-
'knowledge': 'KnowledgeEntityService',
|
|
980
|
-
'metadata': 'MetadataEntityService',
|
|
981
|
-
};
|
|
982
|
-
|
|
983
|
-
const family = entity.family ?? null;
|
|
984
|
-
const hasFamily = family != null;
|
|
985
|
-
const familyBaseRepository = family ? (FAMILY_REPOSITORY_MAP[family] ?? null) : null;
|
|
986
|
-
const familyBaseService = family ? (FAMILY_SERVICE_MAP[family] ?? null) : null;
|
|
987
|
-
|
|
988
1014
|
// ============================================================================
|
|
989
1015
|
// v2: Queries
|
|
990
1016
|
// ============================================================================
|
|
@@ -1157,11 +1183,193 @@ export default {
|
|
|
1157
1183
|
})
|
|
1158
1184
|
: [];
|
|
1159
1185
|
|
|
1186
|
+
// ============================================================================
|
|
1187
|
+
// EVT-7: emits — resolve typed events for create/update/delete use-cases.
|
|
1188
|
+
// ============================================================================
|
|
1189
|
+
//
|
|
1190
|
+
// The `emits:` list is guaranteed-valid at this point — the CLI pre-flight
|
|
1191
|
+
// (`validateEntityEmits`) has already run. Our job is to derive:
|
|
1192
|
+
// • `emitsEvents[]` — one entry per emitted type with payload + mapping.
|
|
1193
|
+
// • `createEventType` / `updateEventType` / `deleteEventType` — the specific
|
|
1194
|
+
// `<entity>_<op>` entries for the three standard CRUD use-cases.
|
|
1195
|
+
// • Payload mapping rules 1..5 (see plan §Payload mapping).
|
|
1196
|
+
//
|
|
1197
|
+
// We re-merge `events/*.yaml` + entity desugar here because we cannot
|
|
1198
|
+
// import the TS generator helpers into a Hygen prompt. The merge is cheap
|
|
1199
|
+
// and has no side effects; the validator has already proven correctness.
|
|
1200
|
+
|
|
1201
|
+
const hasEmits = Array.isArray(emitsBlock) && emitsBlock.length > 0;
|
|
1202
|
+
|
|
1203
|
+
const FIELD_TYPE_TO_TS = {
|
|
1204
|
+
uuid: 'string',
|
|
1205
|
+
string: 'string',
|
|
1206
|
+
number: 'number',
|
|
1207
|
+
boolean: 'boolean',
|
|
1208
|
+
date: 'Date',
|
|
1209
|
+
json: 'Record<string, unknown>',
|
|
1210
|
+
};
|
|
1211
|
+
|
|
1212
|
+
// Load top-level events/<name>.yaml, tolerant of missing dir / bad files.
|
|
1213
|
+
const loadTopLevelEventYamls = (eventsDir) => {
|
|
1214
|
+
if (!fs.existsSync(eventsDir)) return new Map();
|
|
1215
|
+
const byType = new Map();
|
|
1216
|
+
for (const file of fs.readdirSync(eventsDir)) {
|
|
1217
|
+
if (!file.endsWith('.yaml') && !file.endsWith('.yml')) continue;
|
|
1218
|
+
try {
|
|
1219
|
+
const content = fs.readFileSync(path.join(eventsDir, file), 'utf-8');
|
|
1220
|
+
const parsed = yaml.parse(content);
|
|
1221
|
+
if (parsed && typeof parsed === 'object' && typeof parsed.type === 'string') {
|
|
1222
|
+
byType.set(parsed.type, parsed);
|
|
1223
|
+
}
|
|
1224
|
+
} catch {
|
|
1225
|
+
// Silently skip — the main event-codegen-generator surfaces parse errors.
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
return byType;
|
|
1229
|
+
};
|
|
1230
|
+
|
|
1231
|
+
// Desugar entity events: block into top-level-event shape with
|
|
1232
|
+
// `{ type, direction: 'change', aggregate, payload: { <key>: { type, nullable } } }`.
|
|
1233
|
+
const desugarEntityEventsInline = (entityDefinition) => {
|
|
1234
|
+
const out = new Map();
|
|
1235
|
+
const entityName = entityDefinition?.entity?.name;
|
|
1236
|
+
const evs = entityDefinition?.events ?? [];
|
|
1237
|
+
for (const ev of evs) {
|
|
1238
|
+
const payload = {};
|
|
1239
|
+
for (const [key, t] of Object.entries(ev.body ?? {})) {
|
|
1240
|
+
payload[key] = { type: t, nullable: false };
|
|
1241
|
+
}
|
|
1242
|
+
out.set(ev.name, {
|
|
1243
|
+
type: ev.name,
|
|
1244
|
+
direction: 'change',
|
|
1245
|
+
aggregate: entityName,
|
|
1246
|
+
payload,
|
|
1247
|
+
});
|
|
1248
|
+
}
|
|
1249
|
+
return out;
|
|
1250
|
+
};
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* Resolve each emit name into the per-op event descriptor the templates need.
|
|
1254
|
+
*/
|
|
1255
|
+
const resolveEmitsEvents = () => {
|
|
1256
|
+
if (!hasEmits) return [];
|
|
1257
|
+
|
|
1258
|
+
const eventsDir = path.resolve(process.cwd(), 'events');
|
|
1259
|
+
const topLevel = loadTopLevelEventYamls(eventsDir);
|
|
1260
|
+
const sugar = desugarEntityEventsInline(definition);
|
|
1261
|
+
// Top-level wins on collision (same policy as event-codegen-generator).
|
|
1262
|
+
const merged = new Map(sugar);
|
|
1263
|
+
for (const [k, v] of topLevel) merged.set(k, v);
|
|
1264
|
+
|
|
1265
|
+
// Build quick lookups keyed by camelCase for payload-mapping rules 3/4.
|
|
1266
|
+
const entityKeysCamel = new Set(
|
|
1267
|
+
processedFields.map((f) => f.camelName),
|
|
1268
|
+
);
|
|
1269
|
+
|
|
1270
|
+
// DTO keys = the fields actually present on CreateXDto (input-eligible).
|
|
1271
|
+
// The CLP + Clean DTOs derive from the same processedFields list (minus
|
|
1272
|
+
// behaviors-computed fields like createdAt/updatedAt/deletedAt). We
|
|
1273
|
+
// approximate here by using all processedFields — the TODO comments on
|
|
1274
|
+
// each generated line make any miss visually obvious.
|
|
1275
|
+
const dtoKeysCamel = new Set(
|
|
1276
|
+
processedFields.map((f) => f.camelName),
|
|
1277
|
+
);
|
|
1278
|
+
|
|
1279
|
+
return emitsBlock.map((emitName) => {
|
|
1280
|
+
const ev = merged.get(emitName);
|
|
1281
|
+
// `validateEntityEmits` has already guaranteed `ev` is defined. If
|
|
1282
|
+
// somehow we get here with an unknown name (e.g. validator bypassed),
|
|
1283
|
+
// emit a TODO-only mapping so the generated file still parses.
|
|
1284
|
+
const payload = ev?.payload ?? {};
|
|
1285
|
+
const payloadKeys = Object.keys(payload).sort();
|
|
1286
|
+
|
|
1287
|
+
const payloadMap = payloadKeys.map((snakeKey) => {
|
|
1288
|
+
const field = payload[snakeKey];
|
|
1289
|
+
const tsType = FIELD_TYPE_TO_TS[field.type] ?? 'unknown';
|
|
1290
|
+
const tsTypeFinal = field.nullable ? `${tsType} | null` : tsType;
|
|
1291
|
+
const camelKey = camelCase(snakeKey);
|
|
1292
|
+
|
|
1293
|
+
let expression;
|
|
1294
|
+
let todo;
|
|
1295
|
+
|
|
1296
|
+
// Rule 1: <entity>_id or <entityName>Id → entity.id
|
|
1297
|
+
if (
|
|
1298
|
+
snakeKey === `${name}_id` ||
|
|
1299
|
+
camelKey === `${camelName}Id`
|
|
1300
|
+
) {
|
|
1301
|
+
expression = 'entity.id';
|
|
1302
|
+
}
|
|
1303
|
+
// Rule 2: created_by / updated_by → dto.createdBy / dto.updatedBy if present.
|
|
1304
|
+
else if (snakeKey === 'created_by' || snakeKey === 'updated_by') {
|
|
1305
|
+
const dtoKey = camelKey;
|
|
1306
|
+
if (dtoKeysCamel.has(dtoKey)) {
|
|
1307
|
+
expression = `dto.${dtoKey}`;
|
|
1308
|
+
} else {
|
|
1309
|
+
expression = `null as unknown as ${tsTypeFinal}`;
|
|
1310
|
+
todo = `supply ${snakeKey} (not on DTO — wire from auth context)`;
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
// Rule 3: field present on just-created entity → entity.<camelKey>
|
|
1314
|
+
else if (entityKeysCamel.has(camelKey)) {
|
|
1315
|
+
expression = `entity.${camelKey}`;
|
|
1316
|
+
}
|
|
1317
|
+
// Rule 4: field present on input DTO (fallback) → dto.<camelKey>
|
|
1318
|
+
else if (dtoKeysCamel.has(camelKey)) {
|
|
1319
|
+
expression = `dto.${camelKey}`;
|
|
1320
|
+
}
|
|
1321
|
+
// Rule 5: otherwise — null placeholder + TODO.
|
|
1322
|
+
else {
|
|
1323
|
+
expression = `null as unknown as ${tsTypeFinal}`;
|
|
1324
|
+
todo = `supply ${snakeKey}`;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
return {
|
|
1328
|
+
snakeKey,
|
|
1329
|
+
camelKey,
|
|
1330
|
+
tsType: tsTypeFinal,
|
|
1331
|
+
expression,
|
|
1332
|
+
todo,
|
|
1333
|
+
};
|
|
1334
|
+
});
|
|
1335
|
+
|
|
1336
|
+
return {
|
|
1337
|
+
type: emitName,
|
|
1338
|
+
aggregate: ev?.aggregate ?? name,
|
|
1339
|
+
payloadMap,
|
|
1340
|
+
};
|
|
1341
|
+
});
|
|
1342
|
+
};
|
|
1343
|
+
|
|
1344
|
+
const emitsEvents = resolveEmitsEvents();
|
|
1345
|
+
const createEventType =
|
|
1346
|
+
emitsEvents.find((e) => e.type === `${name}_created`) ?? null;
|
|
1347
|
+
const updateEventType =
|
|
1348
|
+
emitsEvents.find((e) => e.type === `${name}_updated`) ?? null;
|
|
1349
|
+
const deleteEventType =
|
|
1350
|
+
emitsEvents.find((e) => e.type === `${name}_deleted`) ?? null;
|
|
1351
|
+
|
|
1352
|
+
// Import paths for the TypedEventBus token + DrizzleClient token/type.
|
|
1353
|
+
// The consumer app wires `@shared/*` aliases to the vendored subsystem
|
|
1354
|
+
// sources (under `<paths.backend_src>/shared/subsystems/…`), matching
|
|
1355
|
+
// where `subsystem install` drops the runtime files and where
|
|
1356
|
+
// `entity new` writes the generated `events/generated/` artifacts. The
|
|
1357
|
+
// `@shared/subsystems/events` path is the barrel at
|
|
1358
|
+
// `<subsystems_root>/events/index.ts`.
|
|
1359
|
+
const eventsTokenImport = '@shared/subsystems/events';
|
|
1360
|
+
const typedEventBusImport = '@shared/subsystems/events';
|
|
1361
|
+
const drizzleTokenImport = '@shared/constants/tokens';
|
|
1362
|
+
const drizzleTypeImport = '@shared/types/drizzle';
|
|
1363
|
+
|
|
1160
1364
|
const locals = {
|
|
1161
1365
|
// Database configuration
|
|
1162
1366
|
databaseDialect,
|
|
1163
1367
|
schemaDir: BASE_PATHS.schemaDir,
|
|
1164
1368
|
|
|
1369
|
+
// Project layout — used by clean-lite-ps prompt-extension to compute
|
|
1370
|
+
// output paths under the configured source root (paths.backend_src).
|
|
1371
|
+
backendSrc: BASE_PATHS.backendSrc,
|
|
1372
|
+
|
|
1165
1373
|
// Entity names
|
|
1166
1374
|
name,
|
|
1167
1375
|
plural,
|
|
@@ -1352,12 +1560,6 @@ export default {
|
|
|
1352
1560
|
isCleanLitePs,
|
|
1353
1561
|
frontendEnabled,
|
|
1354
1562
|
|
|
1355
|
-
// Family
|
|
1356
|
-
family,
|
|
1357
|
-
hasFamily,
|
|
1358
|
-
familyBaseRepository,
|
|
1359
|
-
familyBaseService,
|
|
1360
|
-
|
|
1361
1563
|
// Queries
|
|
1362
1564
|
hasQueries,
|
|
1363
1565
|
processedQueries,
|
|
@@ -1375,6 +1577,17 @@ export default {
|
|
|
1375
1577
|
// Events
|
|
1376
1578
|
hasEvents,
|
|
1377
1579
|
processedEvents,
|
|
1580
|
+
|
|
1581
|
+
// EVT-7: emits (typed auto-emission via TypedEventBus)
|
|
1582
|
+
hasEmits,
|
|
1583
|
+
emitsEvents,
|
|
1584
|
+
createEventType,
|
|
1585
|
+
updateEventType,
|
|
1586
|
+
deleteEventType,
|
|
1587
|
+
eventsTokenImport,
|
|
1588
|
+
typedEventBusImport,
|
|
1589
|
+
drizzleTokenImport,
|
|
1590
|
+
drizzleTypeImport,
|
|
1378
1591
|
};
|
|
1379
1592
|
|
|
1380
1593
|
// ========================================================================
|
|
@@ -1385,7 +1598,17 @@ export default {
|
|
|
1385
1598
|
// template bodies can render without crashing; their `to:` guards resolve
|
|
1386
1599
|
// to null which causes Hygen to skip file writing.
|
|
1387
1600
|
// ========================================================================
|
|
1601
|
+
// EVT-7 note: hasEmits / emitsEvents / *EventType / *Import locals are
|
|
1602
|
+
// already in `locals` above and are architecture-neutral — CLP templates
|
|
1603
|
+
// read the same locals to render typed publish blocks in their use-cases.
|
|
1604
|
+
|
|
1388
1605
|
if (isCleanLitePs) {
|
|
1606
|
+
// Load app-defined patterns (if any) into the registry before the
|
|
1607
|
+
// clean-lite-ps extension reads it. `loadAppPatterns` is idempotent
|
|
1608
|
+
// and deterministic — calling it every run is cheap (one dynamic
|
|
1609
|
+
// import per pattern file) and matches the two-process load story
|
|
1610
|
+
// the registry tests pin down.
|
|
1611
|
+
await ensurePatternsRegistryLoaded();
|
|
1389
1612
|
const { buildCleanLitePsLocals } = await import('./clean-lite-ps/prompt-extension.js');
|
|
1390
1613
|
Object.assign(locals, buildCleanLitePsLocals(definition, locals));
|
|
1391
1614
|
} else {
|
|
@@ -1413,6 +1636,26 @@ export default {
|
|
|
1413
1636
|
serviceBaseImport: '',
|
|
1414
1637
|
repositoryInheritedMethods: [],
|
|
1415
1638
|
serviceInheritedMethods: [],
|
|
1639
|
+
// Generation toggles — needed so CLP template bodies render without crashing
|
|
1640
|
+
// when architecture is 'clean'. The to:/skip_if: guards prevent file writes.
|
|
1641
|
+
generateWrites: true,
|
|
1642
|
+
eavEnabled: false,
|
|
1643
|
+
eavValueTable: false,
|
|
1644
|
+
eavDefinitionEntity: null,
|
|
1645
|
+
eavDefinitionEntityPlural: null,
|
|
1646
|
+
eavDefinitionPascal: null,
|
|
1647
|
+
eavDefinitionPluralPascal: null,
|
|
1648
|
+
hasSearchQuery: false,
|
|
1649
|
+
searchQuery: null,
|
|
1650
|
+
hasExternalIdTracking: false,
|
|
1651
|
+
// PATTERN-5 stubs — defined even for non-CLP architectures so the
|
|
1652
|
+
// CLP template bodies render without `ReferenceError`s. The
|
|
1653
|
+
// to:/skip_if: guards prevent file writes, but EJS still walks the
|
|
1654
|
+
// body on every template.
|
|
1655
|
+
patternName: 'Base',
|
|
1656
|
+
hasPatternConfig: false,
|
|
1657
|
+
patternConfig: null,
|
|
1658
|
+
renderPatternConfigLiteral: () => '{}',
|
|
1416
1659
|
});
|
|
1417
1660
|
}
|
|
1418
1661
|
|
|
@@ -8,9 +8,9 @@ import {
|
|
|
8
8
|
<%_ }) _%>
|
|
9
9
|
} from 'drizzle-orm/pg-core';
|
|
10
10
|
import { type InferSelectModel } from 'drizzle-orm';
|
|
11
|
-
import { <%= fromTable %> } from '
|
|
11
|
+
import { <%= fromTable %> } from '../<%= fromTable %>/<%= from %>.entity';
|
|
12
12
|
<%_ if (from !== to) { _%>
|
|
13
|
-
import { <%= toTable %> } from '
|
|
13
|
+
import { <%= toTable %> } from '../<%= toTable %>/<%= to %>.entity';
|
|
14
14
|
<%_ } _%>
|
|
15
15
|
|
|
16
16
|
// ============================================================================
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import fs from "node:fs";
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
import yaml from "yaml";
|
|
14
|
+
import pluralizePkg from "pluralize";
|
|
14
15
|
|
|
15
16
|
// ============================================================================
|
|
16
17
|
// Naming Helpers (inlined to avoid import issues with Hygen)
|
|
@@ -19,12 +20,7 @@ import yaml from "yaml";
|
|
|
19
20
|
const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
20
21
|
const camelCase = (s) => s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
21
22
|
const pascalCase = (s) => capitalize(camelCase(s));
|
|
22
|
-
const pluralize = (s) =>
|
|
23
|
-
if (s.endsWith("y")) return s.slice(0, -1) + "ies";
|
|
24
|
-
if (s.endsWith("s") || s.endsWith("x") || s.endsWith("ch") || s.endsWith("sh"))
|
|
25
|
-
return s + "es";
|
|
26
|
-
return s + "s";
|
|
27
|
-
};
|
|
23
|
+
const pluralize = (s) => pluralizePkg.plural(s);
|
|
28
24
|
const kebabCase = (s) => s.replace(/_/g, "-");
|
|
29
25
|
|
|
30
26
|
// ============================================================================
|
|
@@ -45,7 +41,7 @@ function deriveRelationshipFKColumns(config) {
|
|
|
45
41
|
}
|
|
46
42
|
|
|
47
43
|
function deriveTableName(config) {
|
|
48
|
-
return config.table ??
|
|
44
|
+
return config.table ?? pluralize(config.name);
|
|
49
45
|
}
|
|
50
46
|
|
|
51
47
|
function collectTypeNames(types) {
|
|
@@ -19,7 +19,7 @@ export class <%= classNames.service %> extends WithAnalytics(
|
|
|
19
19
|
@Optional() @Inject(EVENT_BUS)
|
|
20
20
|
protected override eventBus: any = undefined;
|
|
21
21
|
|
|
22
|
-
constructor(protected readonly repository: <%= classNames.repository %>) {
|
|
22
|
+
constructor(protected override readonly repository: <%= classNames.repository %>) {
|
|
23
23
|
super(repository);
|
|
24
24
|
}
|
|
25
25
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hygen prompt.js — BRIDGE-9 bridge subsystem scaffold (lean variant).
|
|
3
|
+
*
|
|
4
|
+
* Locals resolved by the CLI (src/cli/shared/bridge-scaffold-locals.ts) and
|
|
5
|
+
* forwarded as CLI args. This prompt.js coerces boolean-ish strings back into
|
|
6
|
+
* JS booleans for parity with events / sync (Hygen args arrive as strings).
|
|
7
|
+
*
|
|
8
|
+
* Invoked via:
|
|
9
|
+
* bunx hygen subsystem bridge \
|
|
10
|
+
* --configPath <abs> --generatedKeepPath <abs> \
|
|
11
|
+
* --multiTenant <'true'|'false'> --appName <string>
|
|
12
|
+
*
|
|
13
|
+
* No schema template here — `bridge-delivery.schema.ts` ships unconditionally
|
|
14
|
+
* via `copyRuntime` (BRIDGE-1's `tenant_id` column is always emitted; multi-
|
|
15
|
+
* tenancy is a runtime enforcement concern, not a scaffold-time gate).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
function coerceBool(raw) {
|
|
19
|
+
if (raw === true) return true;
|
|
20
|
+
if (raw === false) return false;
|
|
21
|
+
if (typeof raw === "string") return raw.toLowerCase() === "true";
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default {
|
|
26
|
+
prompt: async ({ args }) => {
|
|
27
|
+
return {
|
|
28
|
+
appName: args.appName ?? "",
|
|
29
|
+
multiTenant: coerceBool(args.multiTenant),
|
|
30
|
+
configPath: args.configPath ?? "codegen.config.yaml",
|
|
31
|
+
generatedKeepPath:
|
|
32
|
+
args.generatedKeepPath ??
|
|
33
|
+
"shared/subsystems/bridge/generated/.gitkeep",
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
to: "<%= configPath %>"
|
|
3
|
+
inject: true
|
|
4
|
+
append: true
|
|
5
|
+
skip_if: "bridge:"
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
bridge:
|
|
9
|
+
# ── Backend selection (core/extension model — see CLAUDE.md) ──
|
|
10
|
+
# 'drizzle' is the production backend (bridge_delivery ledger + outbox
|
|
11
|
+
# drain integration). 'memory' is the synchronous test backend.
|
|
12
|
+
backend: drizzle
|
|
13
|
+
|
|
14
|
+
# ── Multi-tenancy (BRIDGE-8 / ADR-023) ──
|
|
15
|
+
# When true, the three enforcement sites
|
|
16
|
+
# (EventFlowService.publishAndStart, BridgeDeliveryHandler.run,
|
|
17
|
+
# DrizzleBridgeDeliveryRepo.insertDelivery) throw MissingTenantIdError
|
|
18
|
+
# when `tenantId === undefined`. Explicit `null` always passes
|
|
19
|
+
# (cross-tenant work). Pair with `BridgeModule.forRoot({ multiTenant: true })`.
|
|
20
|
+
multi_tenant: false
|