@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
@@ -5,80 +5,103 @@
5
5
  * all variables required by the clean-lite-ps template set.
6
6
  */
7
7
 
8
+ import pluralizePkg from 'pluralize';
9
+ // The patterns barrel has the side effect of pre-registering the five
10
+ // library-shipped patterns (Base / Synced / Activity / Knowledge /
11
+ // Metadata). App-defined patterns are loaded separately in the parent
12
+ // prompt.js via loadAppPatterns() against `codegen.config.yaml patterns:`
13
+ // globs before this helper runs — we only read the registry here.
14
+ import { getPattern } from '../../../../src/patterns/registry.js';
15
+ import '../../../../src/patterns/library/index.js';
16
+
8
17
  // ============================================================================
9
- // Family Base Class Mapping
18
+ // Pattern registry resolution
10
19
  // ============================================================================
11
20
 
12
- const FAMILY_MAP = {
13
- 'synced': {
14
- repositoryBaseClass: 'SyncedEntityRepository',
15
- serviceBaseClass: 'SyncedEntityService',
16
- repositoryBaseImport: '@shared/base-classes/synced-entity-repository',
17
- serviceBaseImport: '@shared/base-classes/synced-entity-service',
18
- repositoryInheritedMethods: [
19
- 'findById, findByIds, list, count, exists, create, update, delete, upsertMany',
20
- 'findByExternalId, findAllByUserId, findVisibleByUserId, syncUpsert',
21
- ],
22
- serviceInheritedMethods: [
23
- 'findById, findByIds, list, count, exists, create, update, delete',
24
- 'findByExternalId, findAllByUserId, findVisibleByUserId',
25
- ],
26
- },
27
- activity: {
28
- repositoryBaseClass: 'ActivityEntityRepository',
29
- serviceBaseClass: 'ActivityEntityService',
30
- repositoryBaseImport: '@shared/base-classes/activity-entity-repository',
31
- serviceBaseImport: '@shared/base-classes/activity-entity-service',
32
- repositoryInheritedMethods: [
33
- 'findById, findByIds, list, count, exists, create, update, delete, upsertMany',
34
- 'findByDateRange, findByUserId, findByOpportunityId, findRecentByOpportunityId',
35
- ],
36
- serviceInheritedMethods: [
37
- 'findById, findByIds, list, count, exists, create, update, delete',
38
- 'findByDateRange, findByUserId, findByOpportunityId, findRecentByOpportunityId',
39
- ],
40
- },
41
- knowledge: {
42
- repositoryBaseClass: 'KnowledgeEntityRepository',
43
- serviceBaseClass: 'KnowledgeEntityService',
44
- repositoryBaseImport: '@shared/base-classes/knowledge-entity-repository',
45
- serviceBaseImport: '@shared/base-classes/knowledge-entity-service',
46
- repositoryInheritedMethods: [
47
- 'findById, findByIds, list, count, exists, create, update, delete, upsertMany',
48
- 'semanticSearch, findPendingByOpportunityId, updateStatus, updateStatusBatch',
49
- ],
50
- serviceInheritedMethods: [
51
- 'findById, findByIds, list, count, exists, create, update, delete',
52
- 'semanticSearch, findPendingByOpportunityId, updateStatus, updateStatusBatch',
53
- ],
54
- },
55
- metadata: {
56
- repositoryBaseClass: 'MetadataEntityRepository',
57
- serviceBaseClass: 'MetadataEntityService',
58
- repositoryBaseImport: '@shared/base-classes/metadata-entity-repository',
59
- serviceBaseImport: '@shared/base-classes/metadata-entity-service',
60
- repositoryInheritedMethods: [
61
- 'findById, findByIds, list, count, exists, create, update, delete, upsertMany',
62
- 'findByEntityIdAndType, listByEntityId, listHistoryByEntityId',
63
- ],
64
- serviceInheritedMethods: [
65
- 'findById, findByIds, list, count, exists, create, update, delete',
66
- 'findByEntityIdAndType, listByEntityId, listHistoryByEntityId',
67
- ],
68
- },
69
- base: {
70
- repositoryBaseClass: 'BaseRepository',
71
- serviceBaseClass: 'BaseService',
72
- repositoryBaseImport: '@shared/base-classes/base-repository',
73
- serviceBaseImport: '@shared/base-classes/base-service',
74
- repositoryInheritedMethods: [
75
- 'findById, findByIds, list, count, exists, create, update, delete, upsertMany',
76
- ],
77
- serviceInheritedMethods: [
78
- 'findById, findByIds, list, count, exists, create, update, delete',
79
- ],
80
- },
81
- };
21
+
22
+ /**
23
+ * Serialize a plain object as an idiomatic TypeScript object literal.
24
+ * Unlike JSON.stringify, this emits bare identifier keys when legal
25
+ * (matching the ADR-031 §4 example) and single-quoted strings. Only
26
+ * the shapes that actually appear in validated pattern configs are
27
+ * supported — strings, numbers, booleans, nulls, nested objects, and
28
+ * arrays of the same. Anything else falls through to JSON.stringify
29
+ * to stay safe.
30
+ */
31
+ function renderPatternConfigLiteral(value, indent = ' ', initialIndent = '') {
32
+ // `currentIndent` is the indent applied to the closing brace of the
33
+ // outermost value; nested lines add one more level of `indent`.
34
+ // Templates that emit this helper inside an already-indented block
35
+ // (e.g. a class body indented by 2 spaces) should pass the block's
36
+ // indent as `initialIndent` so the closing brace and child lines line
37
+ // up correctly with the surrounding code.
38
+ return _renderLiteral(value, indent, initialIndent);
39
+ }
40
+
41
+ function _renderLiteral(value, baseIndent, currentIndent) {
42
+ if (value === null) return 'null';
43
+ if (typeof value === 'string') {
44
+ // Single-quoted TS string with \\ + ' escapes. Matches ADR-031 example style.
45
+ return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
46
+ }
47
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
48
+ if (Array.isArray(value)) {
49
+ if (value.length === 0) return '[]';
50
+ const next = currentIndent + baseIndent;
51
+ const items = value.map((v) => `${next}${_renderLiteral(v, baseIndent, next)}`);
52
+ return `[\n${items.join(',\n')},\n${currentIndent}]`;
53
+ }
54
+ if (typeof value === 'object') {
55
+ const entries = Object.entries(value);
56
+ if (entries.length === 0) return '{}';
57
+ const next = currentIndent + baseIndent;
58
+ const lines = entries.map(([k, v]) => {
59
+ const key = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(k) ? k : `'${k}'`;
60
+ return `${next}${key}: ${_renderLiteral(v, baseIndent, next)}`;
61
+ });
62
+ return `{\n${lines.join(',\n')},\n${currentIndent}}`;
63
+ }
64
+ // Anything else — fall back to a safe JSON serialization.
65
+ return JSON.stringify(value);
66
+ }
67
+
68
+ /**
69
+ * Resolve the base-class locals (repository + service class name + import
70
+ * path + inherited-method comment lines) for an entity by looking up its
71
+ * declared pattern in the shared registry.
72
+ *
73
+ * Resolution order:
74
+ * 1. `entity.pattern` single-pattern case. Returns that pattern's record.
75
+ * 2. `entity.patterns[0]` — multi-pattern case: the first name drives the
76
+ * base-class choice. Subsequent patterns contribute columns + implied
77
+ * behaviors (PATTERN-4 composition check) but do not change the
78
+ * template's repository/service base class.
79
+ * 3. `'Base'` fallback — library pattern that anchors the identity case.
80
+ *
81
+ * Exported for unit-testing; consumers import `buildCleanLitePsLocals`.
82
+ */
83
+ export function resolvePatternBaseClasses(entity) {
84
+ const name =
85
+ (typeof entity.pattern === 'string' && entity.pattern) ||
86
+ (Array.isArray(entity.patterns) && entity.patterns[0]) ||
87
+ 'Base';
88
+ const def = getPattern(name) || getPattern('Base');
89
+ if (!def) {
90
+ throw new Error(
91
+ `Pattern '${name}' is not registered, and the library 'Base' pattern ` +
92
+ `is also missing. Did the patterns barrel fail to load?`,
93
+ );
94
+ }
95
+ return {
96
+ patternName: def.name,
97
+ repositoryBaseClass: def.repositoryClass,
98
+ serviceBaseClass: def.serviceClass,
99
+ repositoryBaseImport: def.repositoryImport,
100
+ serviceBaseImport: def.serviceImport,
101
+ repositoryInheritedMethods: def.repositoryInheritedMethods ?? [],
102
+ serviceInheritedMethods: def.serviceInheritedMethods ?? [],
103
+ };
104
+ }
82
105
 
83
106
  // ============================================================================
84
107
  // Helper utilities
@@ -87,12 +110,7 @@ const FAMILY_MAP = {
87
110
  const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1);
88
111
  const camelCase = (s) => s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
89
112
  const pascalCase = (s) => capitalize(camelCase(s));
90
- const pluralize = (s) => {
91
- if (s.endsWith('y')) return s.slice(0, -1) + 'ies';
92
- if (s.endsWith('s') || s.endsWith('x') || s.endsWith('ch') || s.endsWith('sh'))
93
- return s + 'es';
94
- return s + 's';
95
- };
113
+ const pluralize = (s) => pluralizePkg.plural(s);
96
114
 
97
115
  // ============================================================================
98
116
  // Drizzle type mapping
@@ -128,9 +146,11 @@ const DRIZZLE_IMPORT_MAP = {
128
146
  const ZOD_TYPE_MAP = {
129
147
  string: 'z.string()',
130
148
  integer: 'z.number().int()',
131
- // PG numeric is returned by Drizzle as a string; z.coerce.number() parses
132
- // strings at the boundary while still accepting numeric JSON input.
133
- decimal: 'z.coerce.number()',
149
+ // PG numeric is returned by Drizzle as a string (precision preservation);
150
+ // z.coerce.string() accepts either JSON string or number and coerces to string
151
+ // so the DTO type aligns with the entity type. Do arithmetic at the consumer
152
+ // via Number(value) or a BigNumber library.
153
+ decimal: 'z.coerce.string()',
134
154
  boolean: 'z.boolean()',
135
155
  uuid: 'z.string().uuid()',
136
156
  date: 'z.coerce.date()',
@@ -145,7 +165,7 @@ const ZOD_TYPE_MAP = {
145
165
  const TS_TYPE_MAP = {
146
166
  string: 'string',
147
167
  integer: 'number',
148
- decimal: 'number',
168
+ decimal: 'string',
149
169
  boolean: 'boolean',
150
170
  uuid: 'string',
151
171
  date: 'Date',
@@ -165,6 +185,15 @@ const BEHAVIOR_MANAGED_FIELDS = new Set([
165
185
  'is_active',
166
186
  ]);
167
187
 
188
+ // Fields injected by external_id_tracking behavior — only behavior-managed when
189
+ // that behavior is enabled (otherwise 'provider' etc. could be a legitimate
190
+ // user-declared field, e.g. on field_definition).
191
+ const EXTERNAL_ID_TRACKING_FIELDS = new Set([
192
+ 'external_id',
193
+ 'provider',
194
+ 'provider_metadata',
195
+ ]);
196
+
168
197
  // ============================================================================
169
198
  // Field processors
170
199
  // ============================================================================
@@ -177,7 +206,16 @@ function buildDrizzleChain(fieldName, field, drizzleType) {
177
206
  const required = field.required ?? false;
178
207
  const hasDefault = field.default !== undefined && field.default !== null;
179
208
 
180
- let chain = `${drizzleType}('${fieldName}')`;
209
+ // Drizzle's `date('x')` returns the PgDateString builder by default
210
+ // (data type: string). Force the Date-typed variant so DTO Zod
211
+ // schemas using z.coerce.date() align with the entity type.
212
+ // `timestamp` already defaults to Date — no mode override needed.
213
+ let chain;
214
+ if (drizzleType === 'date') {
215
+ chain = `${drizzleType}('${fieldName}', { mode: 'date' })`;
216
+ } else {
217
+ chain = `${drizzleType}('${fieldName}')`;
218
+ }
181
219
 
182
220
  // Add .notNull() for non-nullable required fields
183
221
  if (required && !nullable) {
@@ -239,10 +277,26 @@ function processFields(fields) {
239
277
  return processed;
240
278
  }
241
279
 
280
+ /**
281
+ * Map YAML on_delete value to the Drizzle onDelete option string.
282
+ *
283
+ * ADR-021 uses snake_case values in YAML (set_null, no_action) while
284
+ * Drizzle expects the SQL keyword form with a space ('set null', 'no action').
285
+ */
286
+ function mapOnDelete(onDelete) {
287
+ const map = {
288
+ restrict: 'restrict',
289
+ cascade: 'cascade',
290
+ set_null: 'set null',
291
+ no_action: 'no action',
292
+ };
293
+ return map[onDelete] ?? 'restrict';
294
+ }
295
+
242
296
  /**
243
297
  * Process belongs_to relationships into BelongsToRelation[]
244
298
  */
245
- function processBelongsTo(relationships) {
299
+ function processBelongsTo(relationships, parentEntityNamePlural) {
246
300
  if (!relationships) return [];
247
301
 
248
302
  const result = [];
@@ -254,6 +308,26 @@ function processBelongsTo(relationships) {
254
308
  const field = rel.foreign_key;
255
309
  const nullable = rel.nullable ?? true;
256
310
  const relatedPlural = pluralize(target);
311
+ const isSelfFk = relatedPlural === parentEntityNamePlural;
312
+
313
+ // on_delete defaults to 'restrict' per ADR-021
314
+ const onDeleteYaml = rel.on_delete ?? 'restrict';
315
+ const onDelete = mapOnDelete(onDeleteYaml);
316
+
317
+ // Relation key: for self-FKs derive from the FK column name
318
+ // (parent_account_id → parentAccount) to avoid colliding with the
319
+ // table's own snake_case name. For non-self-FKs preserve the prior
320
+ // behavior of using the target entity name verbatim so existing
321
+ // consumer code (e.g. drizzle queryBuilder.with.field_definition)
322
+ // keeps working.
323
+ let relationKey;
324
+ if (isSelfFk) {
325
+ // parent_account_id → parent_account → parentAccount
326
+ const base = field.endsWith('_id') ? field.slice(0, -3) : field;
327
+ relationKey = camelCase(base);
328
+ } else {
329
+ relationKey = target;
330
+ }
257
331
 
258
332
  result.push({
259
333
  field,
@@ -264,6 +338,10 @@ function processBelongsTo(relationships) {
264
338
  relatedPlural,
265
339
  nullable,
266
340
  importPath: `../${relatedPlural}/${target}.entity`,
341
+ relationKey,
342
+ isSelfFk,
343
+ onDelete,
344
+ onDeleteYaml,
267
345
  });
268
346
  }
269
347
 
@@ -273,7 +351,7 @@ function processBelongsTo(relationships) {
273
351
  /**
274
352
  * Collect drizzle imports needed for entity fields
275
353
  */
276
- function collectDrizzleImports(processedFields, belongsTo, hasTimestamps, hasSoftDelete) {
354
+ function collectDrizzleImports(processedFields, belongsTo, hasTimestamps, hasSoftDelete, hasExternalIdTracking) {
277
355
  const imports = new Set(['pgTable', 'uuid']);
278
356
 
279
357
  for (const field of processedFields) {
@@ -291,6 +369,12 @@ function collectDrizzleImports(processedFields, belongsTo, hasTimestamps, hasSof
291
369
  imports.add('timestamp');
292
370
  }
293
371
 
372
+ // external_id_tracking behavior injects varchar + jsonb columns
373
+ if (hasExternalIdTracking) {
374
+ imports.add('varchar');
375
+ imports.add('jsonb');
376
+ }
377
+
294
378
  if (belongsTo.length > 0) {
295
379
  imports.add('relations');
296
380
  }
@@ -456,6 +540,81 @@ function processQueries(queriesBlock, processedFields, entityNamePascal) {
456
540
  });
457
541
  }
458
542
 
543
+ // ============================================================================
544
+ // Search query processing
545
+ // ============================================================================
546
+
547
+ /**
548
+ * Process the `queries: - name: search` declarations into template locals.
549
+ *
550
+ * A search query compiles down to:
551
+ * - A `SearchXsUseCase` class composing the entity service's list+count
552
+ * with filter-AND and optional ilike search.
553
+ * - A thin `@Get('search')` controller route that runs the request
554
+ * querystring through a Zod schema before delegating.
555
+ * - A `searchUseCase` / output-path entry so the module/controller
556
+ * templates can emit imports + provider entries.
557
+ *
558
+ * Multiple search declarations per entity aren't supported yet — first
559
+ * one wins and a warning surfaces in the emitted comment. Consumers can
560
+ * split into separate entities if they need multiple search surfaces.
561
+ */
562
+ function processSearchQueries(queriesBlock, processedFields, belongsTo, entityName, entityNamePascal, entityNamePlural, entityNamePluralPascal) {
563
+ if (!queriesBlock || !Array.isArray(queriesBlock)) return null;
564
+ const search = queriesBlock.find((q) => q && q.name === 'search');
565
+ if (!search) return null;
566
+
567
+ const filters = Array.isArray(search.filters) ? search.filters : [];
568
+ if (filters.length === 0) return null;
569
+
570
+ // Build a field->type lookup that covers both regular fields and FK
571
+ // columns from belongs_to relationships — filters commonly target
572
+ // account_id / user_id etc.
573
+ const fieldTypeMap = {};
574
+ for (const pf of processedFields) {
575
+ const entry = {
576
+ tsType: pf.tsType,
577
+ hasChoices: pf.hasChoices,
578
+ choices: pf.choices,
579
+ isUuid: pf.type === 'uuid',
580
+ };
581
+ fieldTypeMap[pf.name] = entry;
582
+ fieldTypeMap[pf.camelName] = entry;
583
+ }
584
+ for (const rel of belongsTo) {
585
+ fieldTypeMap[rel.field] = { tsType: 'string', isUuid: true };
586
+ fieldTypeMap[rel.camelField] = { tsType: 'string', isUuid: true };
587
+ }
588
+
589
+ const resolvedFilters = filters.map((name) => {
590
+ const info = fieldTypeMap[name] || fieldTypeMap[camelCase(name)] || { tsType: 'string' };
591
+ return {
592
+ name,
593
+ camelName: camelCase(name),
594
+ tsType: info.tsType,
595
+ hasChoices: !!info.hasChoices,
596
+ choices: info.choices,
597
+ isUuid: !!info.isUuid,
598
+ // Booleans + numbers need z.coerce.* in the querystring schema.
599
+ isBoolean: info.tsType === 'boolean',
600
+ isNumber: info.tsType === 'number',
601
+ };
602
+ });
603
+
604
+ const searchField = typeof search.search === 'string' ? search.search : null;
605
+ const paginate = search.paginate !== false; // default true
606
+
607
+ return {
608
+ filters: resolvedFilters,
609
+ searchField,
610
+ searchFieldCamel: searchField ? camelCase(searchField) : null,
611
+ paginate,
612
+ useCaseClassName: `Search${entityNamePluralPascal}UseCase`,
613
+ filtersSchemaName: `${entityNamePascal}FiltersSchema`,
614
+ inputTypeName: `Search${entityNamePluralPascal}Input`,
615
+ };
616
+ }
617
+
459
618
  // ============================================================================
460
619
  // Main export
461
620
  // ============================================================================
@@ -474,17 +633,84 @@ export function buildCleanLitePsLocals(definition, baseLocals) {
474
633
  const behaviors = definition.behaviors || [];
475
634
  const queriesBlock = definition.queries || null;
476
635
 
477
- // Source root — configurable via baseLocals.srcRoot or entity.src_root, defaults to 'src'
478
- const srcRoot = baseLocals.srcRoot || entity.src_root || 'src';
636
+ // Source root — resolved in priority order:
637
+ // 1. baseLocals.srcRoot (e.g. set explicitly by tests or callers)
638
+ // 2. entity.src_root (per-entity override in YAML)
639
+ // 3. baseLocals.backendSrc (clean-lite-ps reads paths.backend_src from
640
+ // codegen.config.yaml; prompt.js threads BASE_PATHS.backendSrc here)
641
+ // 4. 'src' (sane default for greenfield projects)
642
+ const srcRoot =
643
+ baseLocals.srcRoot ||
644
+ entity.src_root ||
645
+ baseLocals.backendSrc ||
646
+ 'src';
479
647
 
480
648
  const entityName = entity.name;
481
649
  const entityNamePascal = pascalCase(entityName);
482
650
  const entityNamePlural = entity.plural || pluralize(entityName);
483
651
  const entityNamePluralPascal = pascalCase(entityNamePlural);
484
652
 
485
- // Family resolution
486
- const family = entity.family || 'base';
487
- const familyConfig = FAMILY_MAP[family] || FAMILY_MAP['base'];
653
+ // Generation toggles — `generate.writes` defaults to true so consumers who
654
+ // regenerate pick up create/update/delete use cases without YAML changes.
655
+ // Set `generate.writes: false` in YAML to suppress write-side emission
656
+ // (use cases, controller routes, module providers).
657
+ const generateBlock = definition.generate || {};
658
+ const generateWrites = generateBlock.writes !== false;
659
+
660
+ // EAV (ADR-13) — when true, emit paired reads + transactional compound
661
+ // writes. Consumer must provide `@shared/eav-helpers` and `FieldValueService`.
662
+ const eavEnabled = definition.eav === true;
663
+
664
+ // EAV value-table shape (task #23) — when true, this entity IS the value
665
+ // table. Templates emit compound methods (upsertFieldsTransactional,
666
+ // findMergedByEntity) on the service, upsertCurrentValues on the repo,
667
+ // and auto-wire the paired field-definitions module for DI.
668
+ const eavValueTable = definition.eav_value_table === true;
669
+ const eavDefinitionEntity = eavValueTable
670
+ ? (definition.eav_definition_table || null)
671
+ : null;
672
+ const eavDefinitionEntityPlural = eavDefinitionEntity
673
+ ? pluralize(eavDefinitionEntity)
674
+ : null;
675
+ const eavDefinitionPascal = eavDefinitionEntity
676
+ ? pascalCase(eavDefinitionEntity)
677
+ : null;
678
+ const eavDefinitionPluralPascal = eavDefinitionEntityPlural
679
+ ? pascalCase(eavDefinitionEntityPlural)
680
+ : null;
681
+
682
+ // Pattern resolution — registry-driven (ADR-031, PATTERN-5).
683
+ //
684
+ // The prior PATTERN-3 bridge that lowercased the pattern name to index
685
+ // FAMILY_MAP is gone; the registry returns the canonical record. The
686
+ // shape returned by `resolvePatternBaseClasses` matches the legacy
687
+ // FAMILY_MAP entries verbatim for the five library patterns so the
688
+ // emitted output is byte-identical.
689
+ const patternBase = resolvePatternBaseClasses(entity);
690
+ const { patternName } = patternBase;
691
+ // FAMILY_MAP is gone (PATTERN-5); `patternConfigClasses` is the structural
692
+ // equivalent — repository + service class names + import paths + inherited
693
+ // method comment lists, sourced directly from the pattern registry.
694
+ const patternConfigClasses = {
695
+ repositoryBaseClass: patternBase.repositoryBaseClass,
696
+ serviceBaseClass: patternBase.serviceBaseClass,
697
+ repositoryBaseImport: patternBase.repositoryBaseImport,
698
+ serviceBaseImport: patternBase.serviceBaseImport,
699
+ repositoryInheritedMethods: patternBase.repositoryInheritedMethods,
700
+ serviceInheritedMethods: patternBase.serviceInheritedMethods,
701
+ };
702
+ // Per-entity pattern config: resolve the matching block from
703
+ // `config: { <PatternName>: {...} }`. When the pattern has no
704
+ // configSchema OR the entity doesn't provide one, this stays null and
705
+ // templates emit no `patternConfig` property.
706
+ const patternConfigBlock =
707
+ (definition.config && definition.config[patternName]) ||
708
+ (definition.entity && definition.entity.config && definition.entity.config[patternName]) ||
709
+ null;
710
+ const hasPatternConfig =
711
+ patternConfigBlock != null &&
712
+ typeof patternConfigBlock === 'object' &&
713
+ Object.keys(patternConfigBlock).length > 0;
488
714
 
489
715
  // Process entity fields
490
716
  const processedFields = processFields(fields);
@@ -493,9 +719,28 @@ export function buildCleanLitePsLocals(definition, baseLocals) {
493
719
  const behaviorNames = behaviors.map((b) => (typeof b === 'string' ? b : b.name));
494
720
  const hasTimestamps = behaviorNames.includes('timestamps');
495
721
  const hasSoftDelete = behaviorNames.includes('soft_delete');
722
+ const hasUserTracking = behaviorNames.includes('user_tracking');
723
+ const hasExternalIdTracking = behaviorNames.includes('external_id_tracking');
496
724
 
497
725
  // Process declarative queries
498
- const processedQueries = processQueries(queriesBlock, processedFields, entityNamePascal);
726
+ // Filter out search-named entries — they're handled by
727
+ // processSearchQueries below. processQueries only understands the
728
+ // by-column shape.
729
+ const byColumnQueries = Array.isArray(queriesBlock)
730
+ ? queriesBlock.filter((q) => q && 'by' in q)
731
+ : queriesBlock;
732
+ const processedQueries = processQueries(byColumnQueries, processedFields, entityNamePascal);
733
+ // Process search query declaration (at most one per entity for now).
734
+ const searchQuery = processSearchQueries(
735
+ queriesBlock,
736
+ processedFields,
737
+ [], // belongsTo populated below — late-bind via reassignment
738
+ entityName,
739
+ entityNamePascal,
740
+ entityNamePlural,
741
+ entityNamePluralPascal,
742
+ );
743
+
499
744
  const hasDeclarativeQueries = processedQueries.length > 0;
500
745
  const declarativeQueryClasses = processedQueries.map((q) => q.useCaseClassName);
501
746
  const hasMultiFieldQuery = processedQueries.some((q) => q.hasMultipleParams);
@@ -503,14 +748,43 @@ export function buildCleanLitePsLocals(definition, baseLocals) {
503
748
  const hasViaQuery = processedQueries.some((q) => q.hasVia);
504
749
 
505
750
  // Process belongs_to relationships
506
- const belongsTo = processBelongsTo(relationships);
751
+ const belongsTo = processBelongsTo(relationships, entityNamePlural);
752
+
753
+ // Issue #41 — warn when a soft-delete entity declares non-restrict on_delete on any
754
+ // belongs_to relation. The FK constraint applies to hard-delete only;
755
+ // developers expecting soft-delete cascade must use activeParentFilter() instead.
756
+ if (hasSoftDelete && belongsTo.some((rel) => rel.onDeleteYaml !== 'restrict')) {
757
+ const affectedRels = belongsTo
758
+ .filter((rel) => rel.onDeleteYaml !== 'restrict')
759
+ .map((rel) => `${rel.field} (on_delete: ${rel.onDeleteYaml})`)
760
+ .join(', ');
761
+ console.warn(
762
+ `[codegen] WARNING: ${entityName} has soft_delete behavior but declares non-restrict on_delete on: ${affectedRels}. ` +
763
+ `on_delete is a no-op for soft-delete — only hard-DELETE triggers Postgres cascade rules. ` +
764
+ `See ADR-021: docs/adrs/ADR-021-on-delete-semantics.md`,
765
+ );
766
+ }
767
+
768
+ // Re-process search query now that belongsTo is known — filters can
769
+ // reference FK columns (account_id, user_id) which aren't in
770
+ // processedFields because they're emitted by the belongsTo loop.
771
+ const searchQueryResolved = processSearchQueries(
772
+ queriesBlock,
773
+ processedFields,
774
+ belongsTo,
775
+ entityName,
776
+ entityNamePascal,
777
+ entityNamePlural,
778
+ entityNamePluralPascal,
779
+ );
780
+
507
781
 
508
782
  // Filter FK fields that are already emitted by the clpBelongsTo loop
509
783
  const fkFieldNames = new Set(belongsTo.map((r) => r.field));
510
784
  const nonFkFields = processedFields.filter((f) => !fkFieldNames.has(f.name));
511
785
 
512
786
  // Drizzle imports needed
513
- const drizzleEntityImports = collectDrizzleImports(processedFields, belongsTo, hasTimestamps, hasSoftDelete);
787
+ const drizzleEntityImports = collectDrizzleImports(processedFields, belongsTo, hasTimestamps, hasSoftDelete, hasExternalIdTracking);
514
788
  // Whether relations() import is needed
515
789
  const hasRelationsBlock = belongsTo.length > 0;
516
790
 
@@ -521,11 +795,33 @@ export function buildCleanLitePsLocals(definition, baseLocals) {
521
795
  service: `${srcRoot}/modules/${entityNamePlural}/${entityName}.service.ts`,
522
796
  controller: `${srcRoot}/modules/${entityNamePlural}/${entityName}.controller.ts`,
523
797
  module: `${srcRoot}/modules/${entityNamePlural}/${entityNamePlural}.module.ts`,
798
+ index: `${srcRoot}/modules/${entityNamePlural}/index.ts`,
524
799
  findByIdUseCase: `${srcRoot}/modules/${entityNamePlural}/use-cases/find-${entityName}-by-id.use-case.ts`,
525
800
  listUseCase: `${srcRoot}/modules/${entityNamePlural}/use-cases/list-${entityNamePlural}.use-case.ts`,
801
+ findByIdWithFieldsUseCase: eavEnabled
802
+ ? `${srcRoot}/modules/${entityNamePlural}/use-cases/find-${entityName}-by-id-with-fields.use-case.ts`
803
+ : null,
804
+ listWithFieldsUseCase: eavEnabled
805
+ ? `${srcRoot}/modules/${entityNamePlural}/use-cases/list-${entityNamePlural}-with-fields.use-case.ts`
806
+ : null,
807
+ createUseCase: generateWrites
808
+ ? `${srcRoot}/modules/${entityNamePlural}/use-cases/create-${entityName}.use-case.ts`
809
+ : null,
810
+ updateUseCase: generateWrites
811
+ ? `${srcRoot}/modules/${entityNamePlural}/use-cases/update-${entityName}.use-case.ts`
812
+ : null,
813
+ deleteUseCase: generateWrites
814
+ ? `${srcRoot}/modules/${entityNamePlural}/use-cases/delete-${entityName}.use-case.ts`
815
+ : null,
526
816
  createDto: `${srcRoot}/modules/${entityNamePlural}/dto/create-${entityName}.dto.ts`,
527
817
  updateDto: `${srcRoot}/modules/${entityNamePlural}/dto/update-${entityName}.dto.ts`,
528
818
  outputDto: `${srcRoot}/modules/${entityNamePlural}/dto/${entityName}-output.dto.ts`,
819
+ searchUseCase: searchQueryResolved
820
+ ? `${srcRoot}/modules/${entityNamePlural}/use-cases/search-${entityNamePlural}.use-case.ts`
821
+ : null,
822
+ searchController: searchQueryResolved
823
+ ? `${srcRoot}/modules/${entityNamePlural}/${entityName}-search.controller.ts`
824
+ : null,
529
825
  declarativeQueries: hasDeclarativeQueries
530
826
  ? `${srcRoot}/modules/${entityNamePlural}/use-cases/declarative-queries.ts`
531
827
  : null,
@@ -540,7 +836,14 @@ export function buildCleanLitePsLocals(definition, baseLocals) {
540
836
  controller: `${entityNamePascal}Controller`,
541
837
  module: `${entityNamePluralPascal}Module`,
542
838
  findByIdUseCase: `Find${entityNamePascal}ByIdUseCase`,
839
+ searchUseCase: `Search${entityNamePluralPascal}UseCase`,
840
+ searchController: `${entityNamePascal}SearchController`,
543
841
  listUseCase: `List${entityNamePluralPascal}UseCase`,
842
+ findByIdWithFieldsUseCase: `Find${entityNamePascal}ByIdWithFieldsUseCase`,
843
+ listWithFieldsUseCase: `List${entityNamePluralPascal}WithFieldsUseCase`,
844
+ createUseCase: `Create${entityNamePascal}UseCase`,
845
+ updateUseCase: `Update${entityNamePascal}UseCase`,
846
+ deleteUseCase: `Delete${entityNamePascal}UseCase`,
544
847
  createDto: `Create${entityNamePascal}Dto`,
545
848
  updateDto: `Update${entityNamePascal}Dto`,
546
849
  outputDto: `${entityNamePascal}OutputDto`,
@@ -551,7 +854,8 @@ export function buildCleanLitePsLocals(definition, baseLocals) {
551
854
 
552
855
  // Fields for create DTO: exclude id, behavior-managed fields, and FK fields
553
856
  const createDtoFields = nonFkFields.filter(
554
- (f) => !BEHAVIOR_MANAGED_FIELDS.has(f.name),
857
+ (f) => !BEHAVIOR_MANAGED_FIELDS.has(f.name)
858
+ && !(hasExternalIdTracking && EXTERNAL_ID_TRACKING_FIELDS.has(f.name)),
555
859
  );
556
860
 
557
861
  // FK fields from belongs_to for create/output DTOs
@@ -568,12 +872,36 @@ export function buildCleanLitePsLocals(definition, baseLocals) {
568
872
  zodChainCreate: zodChainForCreate(f),
569
873
  }));
570
874
 
571
- // Build zodChain for each output DTO field (all non-FK fields)
572
- const outputDtoFields = nonFkFields.map((f) => ({
875
+ // Build zodChain for each output DTO field (all non-FK fields).
876
+ // When external_id_tracking is enabled, its fields are injected into the
877
+ // entity table but do not appear in the output DTO (they're metadata).
878
+ const outputDtoSource = hasExternalIdTracking
879
+ ? nonFkFields.filter((f) => !EXTERNAL_ID_TRACKING_FIELDS.has(f.name))
880
+ : nonFkFields;
881
+ const outputDtoFields = outputDtoSource.map((f) => ({
573
882
  ...f,
574
883
  zodChainOutput: zodChainForOutput(f),
575
884
  }));
576
885
 
886
+ // EVT-7: emits locals flow through from baseLocals (prompt.js computed them
887
+ // against the full events registry). When this helper is called in isolation
888
+ // (e.g. from unit tests) baseLocals.hasEmits may be undefined — provide
889
+ // null-safe defaults so the CLP templates emits guards evaluate to false
890
+ // cleanly.
891
+ const hasEmits = Boolean(baseLocals?.hasEmits);
892
+ const emitsEvents = baseLocals?.emitsEvents ?? [];
893
+ const createEventType = baseLocals?.createEventType ?? null;
894
+ const updateEventType = baseLocals?.updateEventType ?? null;
895
+ const deleteEventType = baseLocals?.deleteEventType ?? null;
896
+ const eventsTokenImport =
897
+ baseLocals?.eventsTokenImport ?? '@shared/subsystems/events';
898
+ const typedEventBusImport =
899
+ baseLocals?.typedEventBusImport ?? '@shared/subsystems/events';
900
+ const drizzleTokenImport =
901
+ baseLocals?.drizzleTokenImport ?? '@shared/constants/tokens';
902
+ const drizzleTypeImport =
903
+ baseLocals?.drizzleTypeImport ?? '@shared/types/drizzle';
904
+
577
905
  return {
578
906
  // Clean-Lite-PS identity
579
907
  entityName,
@@ -581,13 +909,46 @@ export function buildCleanLitePsLocals(definition, baseLocals) {
581
909
  entityNamePlural,
582
910
  entityNamePluralPascal,
583
911
 
584
- // Family
585
- family,
586
- ...familyConfig,
912
+ // EVT-7 emits locals (null-safe defaults if baseLocals didn't provide them)
913
+ hasEmits,
914
+ emitsEvents,
915
+ createEventType,
916
+ updateEventType,
917
+ deleteEventType,
918
+ eventsTokenImport,
919
+ typedEventBusImport,
920
+ drizzleTokenImport,
921
+ drizzleTypeImport,
922
+
923
+ // Pattern — registry-driven (ADR-031)
924
+ patternName,
925
+ hasPatternConfig,
926
+ patternConfig: patternConfigBlock,
927
+ renderPatternConfigLiteral,
928
+ ...patternConfigClasses,
587
929
 
588
930
  // Behavior flags (also exposed at top level for template use)
589
931
  hasTimestamps,
590
932
  hasSoftDelete,
933
+ hasUserTracking,
934
+ hasExternalIdTracking,
935
+
936
+ // Generation toggles
937
+ generateWrites,
938
+
939
+ // EAV (ADR-13)
940
+ eavEnabled,
941
+
942
+ // EAV value-table (task #23) — this entity IS the value table.
943
+ eavValueTable,
944
+ eavDefinitionEntity,
945
+ eavDefinitionEntityPlural,
946
+ eavDefinitionPascal,
947
+ eavDefinitionPluralPascal,
948
+ // Search query (#16)
949
+ searchQuery: searchQueryResolved,
950
+ hasSearchQuery: !!searchQueryResolved,
951
+
591
952
 
592
953
  // Output paths
593
954
  clpOutputPaths: outputPaths,