@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.
Files changed (52) hide show
  1. package/README.md +9 -4
  2. package/dist/src/cli/index.js +136 -128
  3. package/dist/src/cli/index.js.map +1 -1
  4. package/dist/src/index.d.ts +16 -0
  5. package/dist/src/index.js +25 -0
  6. package/dist/src/index.js.map +1 -1
  7. package/package.json +10 -1
  8. package/templates/entity/new/backend/application/commands/create.ejs.t +38 -1
  9. package/templates/entity/new/backend/application/commands/delete.ejs.t +41 -1
  10. package/templates/entity/new/backend/application/commands/update.ejs.t +42 -1
  11. package/templates/entity/new/backend/database/repository.ejs.t +33 -3
  12. package/templates/entity/new/backend/domain/repository-interface.ejs.t +6 -3
  13. package/templates/entity/new/backend/modules/core/module.ejs.t +6 -0
  14. package/templates/entity/new/backend/presentation/controller.ejs.t +32 -10
  15. package/templates/entity/new/clean-lite-ps/controller.ejs.t +72 -11
  16. package/templates/entity/new/clean-lite-ps/entity.ejs.t +16 -2
  17. package/templates/entity/new/clean-lite-ps/index.ejs.t +1 -1
  18. package/templates/entity/new/clean-lite-ps/module.ejs.t +45 -2
  19. package/templates/entity/new/clean-lite-ps/prompt-extension.js +459 -98
  20. package/templates/entity/new/clean-lite-ps/repository.ejs.t +57 -4
  21. package/templates/entity/new/clean-lite-ps/search-controller.ejs.t +50 -0
  22. package/templates/entity/new/clean-lite-ps/service.ejs.t +98 -1
  23. package/templates/entity/new/clean-lite-ps/use-cases/create.ejs.t +150 -0
  24. package/templates/entity/new/clean-lite-ps/use-cases/delete.ejs.t +70 -0
  25. package/templates/entity/new/clean-lite-ps/use-cases/find-by-id-with-fields.ejs.t +19 -0
  26. package/templates/entity/new/clean-lite-ps/use-cases/find-by-id.ejs.t +7 -3
  27. package/templates/entity/new/clean-lite-ps/use-cases/list-with-fields.ejs.t +17 -0
  28. package/templates/entity/new/clean-lite-ps/use-cases/search.ejs.t +63 -0
  29. package/templates/entity/new/clean-lite-ps/use-cases/update.ejs.t +153 -0
  30. package/templates/entity/new/prompt.js +284 -41
  31. package/templates/relationship/new/entity.ejs.t +2 -2
  32. package/templates/relationship/new/prompt.js +3 -7
  33. package/templates/relationship/new/service.ejs.t +1 -1
  34. package/templates/subsystem/bridge/generated-keep.ejs.t +4 -0
  35. package/templates/subsystem/bridge/prompt.js +36 -0
  36. package/templates/subsystem/bridge-config/codegen-config-bridge-block.ejs.t +20 -0
  37. package/templates/subsystem/bridge-config/prompt.js +20 -0
  38. package/templates/subsystem/events/domain-events.schema.ejs.t +81 -0
  39. package/templates/subsystem/events/generated-keep.ejs.t +4 -0
  40. package/templates/subsystem/events/prompt.js +39 -0
  41. package/templates/subsystem/events-config/codegen-config-events-block.ejs.t +26 -0
  42. package/templates/subsystem/events-config/prompt.js +20 -0
  43. package/templates/subsystem/jobs/job-orchestration.schema.ejs.t +221 -0
  44. package/templates/subsystem/jobs/main-hook.ejs.t +11 -0
  45. package/templates/subsystem/jobs/prompt.js +40 -0
  46. package/templates/subsystem/jobs/worker.ejs.t +82 -0
  47. package/templates/subsystem/jobs-config/codegen-config-jobs-block.ejs.t +55 -0
  48. package/templates/subsystem/jobs-config/prompt.js +20 -0
  49. package/templates/subsystem/sync/prompt.js +43 -0
  50. package/templates/subsystem/sync/sync-audit.schema.ejs.t +195 -0
  51. package/templates/subsystem/sync-config/codegen-config-sync-block.ejs.t +29 -0
  52. 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
- decimal: "z.number()",
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 '../../<%= fromTable %>/<%= from %>.entity';
11
+ import { <%= fromTable %> } from '../<%= fromTable %>/<%= from %>.entity';
12
12
  <%_ if (from !== to) { _%>
13
- import { <%= toTable %> } from '../../<%= toTable %>/<%= to %>.entity';
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 ?? `${config.name}s`;
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,4 @@
1
+ ---
2
+ to: "<%= generatedKeepPath %>"
3
+ unless_exists: true
4
+ ---
@@ -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