@pattern-stack/codegen 0.6.7 → 0.7.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 (42) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/src/cli/index.js +516 -73
  3. package/dist/src/cli/index.js.map +1 -1
  4. package/dist/src/index.d.ts +208 -1
  5. package/dist/src/index.js +147 -0
  6. package/dist/src/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/src/patterns/library/base-junction-fields.ts +32 -0
  9. package/src/patterns/library/index.ts +7 -0
  10. package/src/patterns/library/junction.pattern.ts +41 -0
  11. package/templates/entity/new/backend/application/queries/get-by-id.ejs.t +3 -3
  12. package/templates/entity/new/backend/application/queries/grouped-index.ejs.t +5 -5
  13. package/templates/entity/new/backend/application/queries/index.ejs.t +3 -0
  14. package/templates/entity/new/backend/application/queries/list.ejs.t +3 -3
  15. package/templates/entity/new/backend/application/queries/relationships.queries.ejs.t +147 -0
  16. package/templates/entity/new/backend/database/repository.ejs.t +36 -176
  17. package/templates/entity/new/backend/domain/entity.ejs.t +0 -44
  18. package/templates/entity/new/backend/domain/grouped-index.ejs.t +4 -60
  19. package/templates/entity/new/backend/domain/index.ejs.t +2 -2
  20. package/templates/entity/new/backend/domain/repository-interface.ejs.t +16 -17
  21. package/templates/entity/new/backend/modules/core/module.ejs.t +10 -0
  22. package/templates/entity/new/backend/presentation/controller.ejs.t +2 -34
  23. package/templates/entity/new/clean-lite-ps/entity.ejs.t +21 -2
  24. package/templates/entity/new/clean-lite-ps/module.ejs.t +27 -2
  25. package/templates/entity/new/clean-lite-ps/prompt-extension.js +108 -9
  26. package/templates/entity/new/clean-lite-ps/repository.ejs.t +33 -1
  27. package/templates/entity/new/clean-lite-ps/service.ejs.t +79 -0
  28. package/templates/entity/new/prompt.js +1 -0
  29. package/templates/junction/new/_inject-parent-module-clp-left.ejs.t +8 -0
  30. package/templates/junction/new/_inject-parent-module-clp-right.ejs.t +8 -0
  31. package/templates/junction/new/_inject-parent-module-import-clp-left.ejs.t +9 -0
  32. package/templates/junction/new/_inject-parent-module-import-clp-right.ejs.t +9 -0
  33. package/templates/junction/new/_inject-parent-service-clp-left.ejs.t +51 -0
  34. package/templates/junction/new/_inject-parent-service-clp-right.ejs.t +48 -0
  35. package/templates/junction/new/_inject-parent-service-import-clp-left.ejs.t +11 -0
  36. package/templates/junction/new/_inject-parent-service-import-clp-right.ejs.t +11 -0
  37. package/templates/junction/new/entity.ejs.t +111 -0
  38. package/templates/junction/new/index.ejs.t +15 -0
  39. package/templates/junction/new/module.ejs.t +37 -0
  40. package/templates/junction/new/prompt.js +492 -0
  41. package/templates/junction/new/repository.ejs.t +67 -0
  42. package/templates/junction/new/service.ejs.t +174 -0
@@ -5,6 +5,8 @@
5
5
  * all variables required by the clean-lite-ps template set.
6
6
  */
7
7
 
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
8
10
  import pluralizePkg from 'pluralize';
9
11
  // The patterns barrel has the side effect of pre-registering the five
10
12
  // library-shipped patterns (Base / Synced / Activity / Knowledge /
@@ -201,7 +203,7 @@ const EXTERNAL_ID_TRACKING_FIELDS = new Set([
201
203
  /**
202
204
  * Build a Drizzle column chain for a field
203
205
  */
204
- function buildDrizzleChain(fieldName, field, drizzleType) {
206
+ function buildDrizzleChain(fieldName, field, drizzleType, enumName) {
205
207
  const nullable = field.nullable ?? false;
206
208
  const required = field.required ?? false;
207
209
  const hasDefault = field.default !== undefined && field.default !== null;
@@ -211,7 +213,11 @@ function buildDrizzleChain(fieldName, field, drizzleType) {
211
213
  // schemas using z.coerce.date() align with the entity type.
212
214
  // `timestamp` already defaults to Date — no mode override needed.
213
215
  let chain;
214
- if (drizzleType === 'date') {
216
+ if (drizzleType === 'enum' && enumName) {
217
+ // Reference the pgEnum declaration emitted at the top of the entity file.
218
+ // The column name argument keeps the snake_case YAML field name.
219
+ chain = `${enumName}('${fieldName}')`;
220
+ } else if (drizzleType === 'date') {
215
221
  chain = `${drizzleType}('${fieldName}', { mode: 'date' })`;
216
222
  } else {
217
223
  chain = `${drizzleType}('${fieldName}')`;
@@ -247,7 +253,15 @@ function processFields(fields) {
247
253
  const choices = field.choices;
248
254
  const hasChoices = Array.isArray(choices) && choices.length > 0;
249
255
 
250
- const drizzleType = DRIZZLE_TYPE_MAP[type] || 'text';
256
+ // Enum-typed fields (or any field with a `choices` list) emit a
257
+ // Postgres-native pgEnum declaration + column reference, so the
258
+ // generated `InferSelectModel` type narrows to the literal union
259
+ // instead of falling back to `string`. Matches the backend pipeline
260
+ // (templates/entity/new/backend/database/schema.ejs.t:66-104).
261
+ const drizzleType = hasChoices
262
+ ? 'enum'
263
+ : (DRIZZLE_TYPE_MAP[type] || 'text');
264
+ const enumName = hasChoices ? camelCase(fieldName) + 'Enum' : null;
251
265
  const tsType = hasChoices
252
266
  ? choices.map((c) => `'${c}'`).join(' | ')
253
267
  : (TS_TYPE_MAP[type] || 'unknown');
@@ -255,7 +269,7 @@ function processFields(fields) {
255
269
  ? `z.enum([${choices.map((c) => `'${c}'`).join(', ')}])`
256
270
  : (ZOD_TYPE_MAP[type] || 'z.unknown()');
257
271
 
258
- const drizzleChain = buildDrizzleChain(fieldName, field, drizzleType);
272
+ const drizzleChain = buildDrizzleChain(fieldName, field, drizzleType, enumName);
259
273
 
260
274
  processed.push({
261
275
  name: fieldName,
@@ -271,6 +285,7 @@ function processFields(fields) {
271
285
  drizzleChain,
272
286
  choices,
273
287
  hasChoices,
288
+ enumName,
274
289
  });
275
290
  }
276
291
 
@@ -293,6 +308,57 @@ function mapOnDelete(onDelete) {
293
308
  return map[onDelete] ?? 'restrict';
294
309
  }
295
310
 
311
+ /**
312
+ * Process has_many relationships into HasManyRelation[].
313
+ *
314
+ * Mirrors processBelongsTo. The `foreign_key` declared on a has_many
315
+ * relationship is the inverse FK living on the *target* entity's table —
316
+ * e.g. `account.relationships.contacts: { foreign_key: account_id }` means
317
+ * contacts.account_id. The method name on AccountRepository would be
318
+ * `findByAccountId`.
319
+ */
320
+ function processHasMany(relationships, parentEntityNamePlural, fs, path, srcRoot) {
321
+ if (!relationships) return [];
322
+
323
+ const result = [];
324
+
325
+ for (const [relName, rel] of Object.entries(relationships)) {
326
+ if (rel.type !== 'has_many') continue;
327
+
328
+ const target = rel.target;
329
+ const inverseForeignKey = rel.foreign_key;
330
+ const targetPlural = pluralize(target);
331
+ const isSelfRef = targetPlural === parentEntityNamePlural;
332
+
333
+ // Check whether the target entity has already been generated.
334
+ // Only include targets that exist so the import block doesn't
335
+ // reference files that aren't on disk yet (two-pass generation).
336
+ let targetExists = false;
337
+ if (fs && path && srcRoot) {
338
+ const nestedPath = path.resolve(srcRoot, 'modules', targetPlural, `${target}.entity.ts`);
339
+ const flatPath = path.resolve(srcRoot, 'modules', `${target}.entity.ts`);
340
+ targetExists = fs.existsSync(nestedPath) || fs.existsSync(flatPath) || isSelfRef;
341
+ } else {
342
+ targetExists = isSelfRef;
343
+ }
344
+
345
+ result.push({
346
+ name: relName,
347
+ target,
348
+ targetClass: pascalCase(target),
349
+ targetPlural,
350
+ inverseForeignKey,
351
+ inverseForeignKeyCamel: camelCase(inverseForeignKey),
352
+ inverseForeignKeyPascal: pascalCase(inverseForeignKey),
353
+ isSelfRef,
354
+ targetExists,
355
+ importPath: `../${targetPlural}/${target}.repository`,
356
+ });
357
+ }
358
+
359
+ return result;
360
+ }
361
+
296
362
  /**
297
363
  * Process belongs_to relationships into BelongsToRelation[]
298
364
  */
@@ -351,10 +417,16 @@ function processBelongsTo(relationships, parentEntityNamePlural) {
351
417
  /**
352
418
  * Collect drizzle imports needed for entity fields
353
419
  */
354
- function collectDrizzleImports(processedFields, belongsTo, hasTimestamps, hasSoftDelete, hasExternalIdTracking) {
420
+ function collectDrizzleImports(processedFields, belongsTo, hasTimestamps, hasSoftDelete, hasExternalIdTracking, hasMany = []) {
355
421
  const imports = new Set(['pgTable', 'uuid']);
356
422
 
357
423
  for (const field of processedFields) {
424
+ if (field.drizzleType === 'enum') {
425
+ // Enum columns reference a `pgEnum` declaration emitted at the top
426
+ // of the entity file; the helper itself comes from drizzle-orm/pg-core.
427
+ imports.add('pgEnum');
428
+ continue;
429
+ }
358
430
  const importName = DRIZZLE_IMPORT_MAP[field.drizzleType];
359
431
  if (importName) imports.add(importName);
360
432
  }
@@ -375,7 +447,7 @@ function collectDrizzleImports(processedFields, belongsTo, hasTimestamps, hasSof
375
447
  imports.add('jsonb');
376
448
  }
377
449
 
378
- if (belongsTo.length > 0) {
450
+ if (belongsTo.length > 0 || hasMany.length > 0) {
379
451
  imports.add('relations');
380
452
  }
381
453
 
@@ -750,6 +822,9 @@ export function buildCleanLitePsLocals(definition, baseLocals) {
750
822
  // Process belongs_to relationships
751
823
  const belongsTo = processBelongsTo(relationships, entityNamePlural);
752
824
 
825
+ // Process has_many relationships (CGP-358b)
826
+ const hasMany = processHasMany(relationships, entityNamePlural, fs, path, srcRoot);
827
+
753
828
  // Issue #41 — warn when a soft-delete entity declares non-restrict on_delete on any
754
829
  // belongs_to relation. The FK constraint applies to hard-delete only;
755
830
  // developers expecting soft-delete cascade must use activeParentFilter() instead.
@@ -783,10 +858,22 @@ export function buildCleanLitePsLocals(definition, baseLocals) {
783
858
  const fkFieldNames = new Set(belongsTo.map((r) => r.field));
784
859
  const nonFkFields = processedFields.filter((f) => !fkFieldNames.has(f.name));
785
860
 
861
+ // Enum field declarations — surface a separate collection so the entity
862
+ // template can emit `export const xEnum = pgEnum('x', [...])` ahead of
863
+ // the `pgTable(...)` block. Both FK-filtered and unfiltered processing
864
+ // include the same enum fields; they're never FKs.
865
+ const clpEnumFields = processedFields
866
+ .filter((f) => f.hasChoices && f.enumName)
867
+ .map((f) => ({
868
+ enumName: f.enumName,
869
+ dbName: f.name,
870
+ choices: f.choices,
871
+ }));
872
+
786
873
  // Drizzle imports needed
787
- const drizzleEntityImports = collectDrizzleImports(processedFields, belongsTo, hasTimestamps, hasSoftDelete, hasExternalIdTracking);
788
- // Whether relations() import is needed
789
- const hasRelationsBlock = belongsTo.length > 0;
874
+ const drizzleEntityImports = collectDrizzleImports(processedFields, belongsTo, hasTimestamps, hasSoftDelete, hasExternalIdTracking, hasMany);
875
+ // Whether relations() import is needed (CGP-358b: also trigger on has_many)
876
+ const hasRelationsBlock = belongsTo.length > 0 || hasMany.length > 0;
790
877
 
791
878
  // Output paths
792
879
  const outputPaths = {
@@ -980,6 +1067,13 @@ export function buildCleanLitePsLocals(definition, baseLocals) {
980
1067
  // Drizzle
981
1068
  clpDrizzleImports: drizzleEntityImports,
982
1069
  clpHasRelationsBlock: hasRelationsBlock,
1070
+ // A self-referential belongs_to FK requires the `references()` callback
1071
+ // to carry a `: AnyPgColumn` return-type annotation; otherwise TypeScript's
1072
+ // strict mode flags the table const with TS7022/TS7024 (circular initializer).
1073
+ // Surfaced by the cgp-62 relationship-scenario smoke when generating a CRM
1074
+ // account with a `parent_account_id` self-FK.
1075
+ clpHasSelfFk: belongsTo.some((rel) => rel.isSelfFk),
1076
+ clpEnumFields,
983
1077
 
984
1078
  // Declarative queries
985
1079
  processedQueries,
@@ -988,5 +1082,10 @@ export function buildCleanLitePsLocals(definition, baseLocals) {
988
1082
  hasMultiFieldQuery,
989
1083
  hasOrderedQuery,
990
1084
  hasViaQuery,
1085
+
1086
+ // CGP-358b: has_many relationships for service-layer composition
1087
+ clpHasMany: hasMany,
1088
+ clpHasManyRelations: hasMany.length > 0,
1089
+ clpExistingHasMany: hasMany.filter((r) => r.targetExists),
991
1090
  };
992
1091
  }
@@ -4,7 +4,17 @@ skip_if: "<%= typeof clpOutputPaths === 'undefined' %>"
4
4
  force: true
5
5
  ---
6
6
  import { Injectable, Inject } from '@nestjs/common';
7
- <% if (hasDeclarativeQueries) { -%>
7
+ <%_
8
+ // CGP-358: FK methods with opts take priority over same-named declarative query impl.
9
+ // Always emit FK methods; skip declarative body when FK covers same name.
10
+ const _fkMethods = (typeof clpBelongsTo !== 'undefined') ? clpBelongsTo : [];
11
+ const _fkMethodNamesCLP = new Set(_fkMethods.map(rel => {
12
+ const _p = rel.camelField.charAt(0).toUpperCase() + rel.camelField.slice(1);
13
+ return `findBy${_p}`;
14
+ }));
15
+ const _needsEq = hasDeclarativeQueries || _fkMethods.length > 0;
16
+ _%>
17
+ <% if (_needsEq) { -%>
8
18
  import { eq<%= hasMultiFieldQuery ? ', and' : '' %><%= hasOrderedQuery ? ', desc, asc' : '' %> } from 'drizzle-orm';
9
19
  <% } -%>
10
20
  <% if (eavValueTable) { -%>
@@ -48,6 +58,12 @@ export class <%= classNames.repository %> extends <%= repositoryBaseClass %><<%=
48
58
  // Declarative queries (from queries: block in entity YAML)
49
59
  // ═══════════════════════════════════════════════════════════════════════
50
60
  <%_ processedQueries.forEach((q) => { _%>
61
+ <%_
62
+ // CGP-358: Skip declarative impl when a FK method covers this method name.
63
+ // FK methods accept opts, making them a superset of a plain non-unique single-param query.
64
+ const _skipClpDq = _fkMethodNamesCLP.has(q.methodName) && !q.isUnique && !q.hasVia && !q.hasSelect;
65
+ _%>
66
+ <%_ if (!_skipClpDq) { _%>
51
67
 
52
68
  async <%= q.methodName %>(<%- q.params.map(p => `${p.camelName}: ${p.tsType}`).join(', ') %>): Promise<<%- q.returnType %>> {
53
69
  <% if (q.isUnique) { -%>
@@ -61,11 +77,27 @@ export class <%= classNames.repository %> extends <%= repositoryBaseClass %><<%=
61
77
  return rows as <%= classNames.entity %>[];
62
78
  <% } -%>
63
79
  }
80
+ <%_ } _%>
64
81
  <%_ }) _%>
65
82
  <% } else { -%>
66
83
 
67
84
  // TODO: Add entity-specific query methods here.
68
85
  <% } -%>
86
+ <%_ if (_fkMethods.length > 0) { _%>
87
+
88
+ // ═══════════════════════════════════════════════════════════════════════
89
+ // FK traversal methods (from belongs_to relationships — CGP-358b)
90
+ // Called by service-layer composition methods on the inverse (has_many) side.
91
+ // ═══════════════════════════════════════════════════════════════════════
92
+ <%_ _fkMethods.forEach(rel => { _%>
93
+
94
+ async findBy<%= rel.camelField.charAt(0).toUpperCase() + rel.camelField.slice(1) %>(id: string, opts?: { cursor?: string; limit?: number }): Promise<<%= classNames.entity %>[]> {
95
+ let q = this.baseQuery().where(eq(this.table['<%= rel.camelField %>'], id));
96
+ if (opts?.limit) q = (q as any).limit(opts.limit);
97
+ return (await q) as <%= classNames.entity %>[];
98
+ }
99
+ <%_ }) _%>
100
+ <%_ } _%>
69
101
  <% if (eavValueTable) { -%>
70
102
 
71
103
  // ═══════════════════════════════════════════════════════════════════════
@@ -17,6 +17,22 @@ import { toEavRows, mergeEavRows } from '@shared/eav-helpers';
17
17
  import type { DrizzleTx } from '@shared/types/drizzle';
18
18
  import { <%= eavDefinitionPascal %>Repository } from '../<%= eavDefinitionEntityPlural %>/<%= eavDefinitionEntity %>.repository';
19
19
  <% } -%>
20
+ <%_ /* CGP-358b — service-layer composition: import target repos for belongs_to relationships */ _%>
21
+ <%_ if (typeof clpBelongsTo !== 'undefined') { _%>
22
+ <%_ const uniqueBelongsToTargets = [...new Map(clpBelongsTo.filter(r => !r.isSelfFk).map(r => [r.relatedEntity, r])).values()]; _%>
23
+ <%_ uniqueBelongsToTargets.forEach(rel => { _%>
24
+ import { <%= rel.relatedEntityPascal %>Repository } from '../<%= rel.relatedPlural %>/<%= rel.relatedEntity %>.repository';
25
+ import type { <%= rel.relatedEntityPascal %> } from '../<%= rel.relatedPlural %>/<%= rel.relatedEntity %>.entity';
26
+ <%_ }) _%>
27
+ <%_ } _%>
28
+ <%_ /* CGP-358b — import target repos for has_many relationships */ _%>
29
+ <%_ if (typeof clpExistingHasMany !== 'undefined') { _%>
30
+ <%_ const uniqueHasManyTargets = [...new Map(clpExistingHasMany.filter(r => !r.isSelfRef).map(r => [r.target, r])).values()]; _%>
31
+ <%_ uniqueHasManyTargets.forEach(rel => { _%>
32
+ import { <%= rel.targetClass %>Repository } from '../<%= rel.targetPlural %>/<%= rel.target %>.repository';
33
+ import type { <%= rel.targetClass %> } from '../<%= rel.targetPlural %>/<%= rel.target %>.entity';
34
+ <%_ }) _%>
35
+ <%_ } _%>
20
36
 
21
37
  @Injectable()
22
38
  export class <%= classNames.service %> extends WithAnalytics(
@@ -43,6 +59,20 @@ export class <%= classNames.service %> extends WithAnalytics(
43
59
  <% if (eavValueTable) { -%>
44
60
  private readonly definitionRepo: <%= eavDefinitionPascal %>Repository,
45
61
  <% } -%>
62
+ <%_ /* CGP-358b — inject target repos for belongs_to (non-self-ref) */ _%>
63
+ <%_ if (typeof clpBelongsTo !== 'undefined') { _%>
64
+ <%_ const uniqueBelongsToTargets2 = [...new Map(clpBelongsTo.filter(r => !r.isSelfFk).map(r => [r.relatedEntity, r])).values()]; _%>
65
+ <%_ uniqueBelongsToTargets2.forEach(rel => { _%>
66
+ private readonly <%= rel.relatedEntity.replace(/_([a-z])/g, (_, c) => c.toUpperCase()) %>Repo: <%= rel.relatedEntityPascal %>Repository,
67
+ <%_ }) _%>
68
+ <%_ } _%>
69
+ <%_ /* CGP-358b — inject target repos for has_many (non-self-ref) */ _%>
70
+ <%_ if (typeof clpExistingHasMany !== 'undefined') { _%>
71
+ <%_ const uniqueHasManyTargets2 = [...new Map(clpExistingHasMany.filter(r => !r.isSelfRef).map(r => [r.target, r])).values()]; _%>
72
+ <%_ uniqueHasManyTargets2.forEach(rel => { _%>
73
+ private readonly <%= rel.target.replace(/_([a-z])/g, (_, c) => c.toUpperCase()) %>Repo: <%= rel.targetClass %>Repository,
74
+ <%_ }) _%>
75
+ <%_ } _%>
46
76
  ) {
47
77
  super(repository);
48
78
  }
@@ -67,6 +97,55 @@ export class <%= classNames.service %> extends WithAnalytics(
67
97
  }
68
98
  <%_ }) _%>
69
99
  <% } %>
100
+ <%_ /* CGP-358b — service-layer composition methods for relationships */ _%>
101
+ <%_ const hasBelongsToComposition = typeof clpBelongsTo !== 'undefined' && clpBelongsTo.length > 0; _%>
102
+ <%_ const hasHasManyComposition = typeof clpExistingHasMany !== 'undefined' && clpExistingHasMany.length > 0; _%>
103
+ <%_ if (hasBelongsToComposition || hasHasManyComposition) { _%>
104
+ // ═══════════════════════════════════════════════════════════════════════
105
+ // Relationship composition methods (CGP-358b / CGP-62)
106
+ // Two queries, no SQL JOIN. Core-contract path; relations() const stays
107
+ // as opt-in extension for hand-written Drizzle queries.
108
+ // ═══════════════════════════════════════════════════════════════════════
109
+ <%_ } _%>
110
+ <%_ if (hasBelongsToComposition) { _%>
111
+ <%_ clpBelongsTo.forEach(rel => { _%>
112
+ <%_ const relCamel = rel.relatedEntity.replace(/_([a-z])/g, (_, c) => c.toUpperCase()); _%>
113
+ <%_ const entityCamel = entityName.replace(/_([a-z])/g, (_, c) => c.toUpperCase()); _%>
114
+
115
+ /**
116
+ * Fetch the <%= rel.relatedEntityPascal %> parent for this <%= entityNamePascal %>.
117
+ * Two repo calls: find self by id → find target by FK.
118
+ */
119
+ async <%= rel.relationKey %>(<%- entityCamel %>Id: string): Promise<<%= rel.relatedEntityPascal %> | null> {
120
+ const entity = await this.repository.findById(<%- entityCamel %>Id);
121
+ if (!entity) return null;
122
+ <%_ if (rel.isSelfFk) { _%>
123
+ return entity.<%= rel.camelField %> ? this.repository.findById(entity.<%= rel.camelField %>) : null;
124
+ <%_ } else { _%>
125
+ return entity.<%= rel.camelField %> ? this.<%= relCamel %>Repo.findById(entity.<%= rel.camelField %>) : null;
126
+ <%_ } _%>
127
+ }
128
+ <%_ }) _%>
129
+ <%_ } _%>
130
+ <%_ if (hasHasManyComposition) { _%>
131
+ <%_ clpExistingHasMany.forEach(rel => { _%>
132
+ <%_ const relCamel = rel.target.replace(/_([a-z])/g, (_, c) => c.toUpperCase()); _%>
133
+ <%_ const entityCamel = entityName.replace(/_([a-z])/g, (_, c) => c.toUpperCase()); _%>
134
+ <%_ const fkPascal = rel.inverseForeignKeyPascal; _%>
135
+
136
+ /**
137
+ * Fetch <%= rel.name %> for this <%= entityNamePascal %> by FK traversal.
138
+ * Single repo call with optional cursor/limit pagination.
139
+ */
140
+ async <%= rel.name %>(<%- entityCamel %>Id: string, opts?: { cursor?: string; limit?: number }): Promise<<%= rel.targetClass %>[]> {
141
+ <%_ if (rel.isSelfRef) { _%>
142
+ return this.repository.findBy<%= fkPascal %>(<%- entityCamel %>Id, opts);
143
+ <%_ } else { _%>
144
+ return this.<%= relCamel %>Repo.findBy<%= fkPascal %>(<%- entityCamel %>Id, opts);
145
+ <%_ } _%>
146
+ }
147
+ <%_ }) _%>
148
+ <%_ } _%>
70
149
  <% if (eavEnabled) { %>
71
150
  /**
72
151
  * EAV paired read (ADR-13): fetch the entity and merge dynamic `field_values`
@@ -568,6 +568,7 @@ export default {
568
568
  moduleToGetByIdQuery: importHelpers.moduleToQuery(name, fileNames.getByIdQuery.replace('.ts', '')),
569
569
  moduleToListQuery: importHelpers.moduleToQuery(name, fileNames.listQuery.replace('.ts', '')),
570
570
  moduleToDeclarativeQueries: importHelpers.moduleToQuery(name, 'declarative-queries'),
571
+ moduleToRelationshipQueries: importHelpers.moduleToQuery(name, 'relationships.queries'),
571
572
  moduleToCreateCommand: importHelpers.moduleToCommand(name, fileNames.createCommand.replace('.ts', '')),
572
573
  moduleToUpdateCommand: importHelpers.moduleToCommand(name, fileNames.updateCommand.replace('.ts', '')),
573
574
  moduleToDeleteCommand: importHelpers.moduleToCommand(name, fileNames.deleteCommand.replace('.ts', '')),
@@ -0,0 +1,8 @@
1
+ ---
2
+ to: "<%= architecture === 'clean-lite-ps' && exposeOnParent.left ? parentModulePathLeft : '' %>"
3
+ inject: true
4
+ after: " DatabaseModule,"
5
+ skip_if: "<%= classNames.module %>"
6
+ ---
7
+ // CGP-60 — junction module (forwardRef breaks the parent↔junction module cycle)
8
+ forwardRef(() => <%= classNames.module %>),
@@ -0,0 +1,8 @@
1
+ ---
2
+ to: "<%= architecture === 'clean-lite-ps' && exposeOnParent.right ? parentModulePathRight : '' %>"
3
+ inject: true
4
+ after: " DatabaseModule,"
5
+ skip_if: "<%= classNames.module %>"
6
+ ---
7
+ // CGP-60 — junction module (forwardRef breaks the parent↔junction module cycle)
8
+ forwardRef(() => <%= classNames.module %>),
@@ -0,0 +1,9 @@
1
+ ---
2
+ to: "<%= architecture === 'clean-lite-ps' && exposeOnParent.left ? parentModulePathLeft : '' %>"
3
+ inject: true
4
+ after: "from '@nestjs/common';"
5
+ skip_if: "<%= classNames.module %> }"
6
+ ---
7
+ // CGP-60 — junction module wiring
8
+ import { forwardRef } from '@nestjs/common';
9
+ import { <%= classNames.module %> } from '<%= junctionModuleImportFromLeft %>';
@@ -0,0 +1,9 @@
1
+ ---
2
+ to: "<%= architecture === 'clean-lite-ps' && exposeOnParent.right ? parentModulePathRight : '' %>"
3
+ inject: true
4
+ after: "from '@nestjs/common';"
5
+ skip_if: "<%= classNames.module %> }"
6
+ ---
7
+ // CGP-60 — junction module wiring
8
+ import { forwardRef } from '@nestjs/common';
9
+ import { <%= classNames.module %> } from '<%= junctionModuleImportFromRight %>';
@@ -0,0 +1,51 @@
1
+ ---
2
+ to: "<%= architecture === 'clean-lite-ps' && exposeOnParent.left ? parentServicePathLeft : '' %>"
3
+ inject: true
4
+ before: "// Inherited from"
5
+ skip_if: "<%= injectionMarkerLeft %>"
6
+ ---
7
+
8
+ // ═══════════════════════════════════════════════════════════════════════
9
+ // CGP-60 — fan-out to <%= rightEntityPascal %> (junction: <%= name %>)
10
+ // Delegates to <%= classNames.service %>. Per-junction marker keeps
11
+ // idempotency; multiple junctions on the same parent each emit their own
12
+ // block. `forwardRef` resolves the circular module import (parent module
13
+ // imports junction module; junction module imports parent modules for
14
+ // repo DI).
15
+ // ═══════════════════════════════════════════════════════════════════════
16
+ <%= injectionMarkerLeft %>
17
+
18
+ @Inject(forwardRef(() => <%= classNames.service %>))
19
+ private readonly <%= entityNameCamel %>Service!: <%= classNames.service %>;
20
+
21
+ async attach<%= rightEntityPascal %>(
22
+ <%= leftEntityCamel %>Id: string,
23
+ <%= rightEntityCamel %>Id: string,
24
+ link?: <%= entityNamePascal %>LinkInput,
25
+ ): Promise<<%= entityNamePascal %>> {
26
+ return this.<%= entityNameCamel %>Service.attach(<%= leftEntityCamel %>Id, <%= rightEntityCamel %>Id, link);
27
+ }
28
+
29
+ async detach<%= rightEntityPascal %>(
30
+ <%= leftEntityCamel %>Id: string,
31
+ <%= rightEntityCamel %>Id: string,
32
+ ): Promise<void> {
33
+ return this.<%= entityNameCamel %>Service.detach(<%= leftEntityCamel %>Id, <%= rightEntityCamel %>Id);
34
+ }
35
+
36
+ async <%= rightEntityPlural %>List(
37
+ <%= leftEntityCamel %>Id: string,
38
+ opts?: { cursor?: string; limit?: number },
39
+ ): Promise<Array<{ entity: <%= rightEntityPascal %>; link: <%= entityNamePascal %> }>> {
40
+ return this.<%= entityNameCamel %>Service.listAssoc('left', <%= leftEntityCamel %>Id, opts) as Promise<
41
+ Array<{ entity: <%= rightEntityPascal %>; link: <%= entityNamePascal %> }>
42
+ >;
43
+ }
44
+
45
+ async <%= rightEntityPlural %>SetPrimary(
46
+ <%= leftEntityCamel %>Id: string,
47
+ <%= rightEntityCamel %>Id: string,
48
+ ): Promise<void> {
49
+ return this.<%= entityNameCamel %>Service.setPrimary(<%= leftEntityCamel %>Id, <%= rightEntityCamel %>Id);
50
+ }
51
+
@@ -0,0 +1,48 @@
1
+ ---
2
+ to: "<%= architecture === 'clean-lite-ps' && exposeOnParent.right ? parentServicePathRight : '' %>"
3
+ inject: true
4
+ before: "// Inherited from"
5
+ skip_if: "<%= injectionMarkerRight %>"
6
+ ---
7
+
8
+ // ═══════════════════════════════════════════════════════════════════════
9
+ // CGP-60 — fan-out to <%= leftEntityPascal %> (junction: <%= name %>)
10
+ // Delegates to <%= classNames.service %>. See left-side block for the
11
+ // forwardRef + per-junction marker rationale.
12
+ // ═══════════════════════════════════════════════════════════════════════
13
+ <%= injectionMarkerRight %>
14
+
15
+ @Inject(forwardRef(() => <%= classNames.service %>))
16
+ private readonly <%= entityNameCamel %>Service!: <%= classNames.service %>;
17
+
18
+ async addTo<%= leftEntityPascal %>(
19
+ <%= rightEntityCamel %>Id: string,
20
+ <%= leftEntityCamel %>Id: string,
21
+ link?: <%= entityNamePascal %>LinkInput,
22
+ ): Promise<<%= entityNamePascal %>> {
23
+ return this.<%= entityNameCamel %>Service.attach(<%= leftEntityCamel %>Id, <%= rightEntityCamel %>Id, link);
24
+ }
25
+
26
+ async removeFrom<%= leftEntityPascal %>(
27
+ <%= rightEntityCamel %>Id: string,
28
+ <%= leftEntityCamel %>Id: string,
29
+ ): Promise<void> {
30
+ return this.<%= entityNameCamel %>Service.detach(<%= leftEntityCamel %>Id, <%= rightEntityCamel %>Id);
31
+ }
32
+
33
+ async <%= leftEntityPlural %>List(
34
+ <%= rightEntityCamel %>Id: string,
35
+ opts?: { cursor?: string; limit?: number },
36
+ ): Promise<Array<{ entity: <%= leftEntityPascal %>; link: <%= entityNamePascal %> }>> {
37
+ return this.<%= entityNameCamel %>Service.listAssoc('right', <%= rightEntityCamel %>Id, opts) as Promise<
38
+ Array<{ entity: <%= leftEntityPascal %>; link: <%= entityNamePascal %> }>
39
+ >;
40
+ }
41
+
42
+ async <%= leftEntityPlural %>SetPrimary(
43
+ <%= rightEntityCamel %>Id: string,
44
+ <%= leftEntityCamel %>Id: string,
45
+ ): Promise<void> {
46
+ return this.<%= entityNameCamel %>Service.setPrimary(<%= leftEntityCamel %>Id, <%= rightEntityCamel %>Id);
47
+ }
48
+
@@ -0,0 +1,11 @@
1
+ ---
2
+ to: "<%= architecture === 'clean-lite-ps' && exposeOnParent.left ? parentServicePathLeft : '' %>"
3
+ inject: true
4
+ after: "from '@nestjs/common';"
5
+ skip_if: "<%= classNames.service %> }"
6
+ ---
7
+ // CGP-60 — junction service + types (forwardRef resolves circular module dep)
8
+ import { forwardRef } from '@nestjs/common';
9
+ import { <%= classNames.service %>, <%= entityNamePascal %>LinkInput } from '<%= junctionServiceImportFromLeft %>';
10
+ import type { <%= entityNamePascal %> } from '../<%= entityNamePlural %>/<%= name %>.entity';
11
+ import type { <%= rightEntityPascal %> } from '<%= rightEntityImportFromJunction %>';
@@ -0,0 +1,11 @@
1
+ ---
2
+ to: "<%= architecture === 'clean-lite-ps' && exposeOnParent.right ? parentServicePathRight : '' %>"
3
+ inject: true
4
+ after: "from '@nestjs/common';"
5
+ skip_if: "<%= classNames.service %> }"
6
+ ---
7
+ // CGP-60 — junction service + types (forwardRef resolves circular module dep)
8
+ import { forwardRef } from '@nestjs/common';
9
+ import { <%= classNames.service %>, <%= entityNamePascal %>LinkInput } from '<%= junctionServiceImportFromRight %>';
10
+ import type { <%= entityNamePascal %> } from '../<%= entityNamePlural %>/<%= name %>.entity';
11
+ import type { <%= leftEntityPascal %> } from '<%= leftEntityImportFromJunction %>';
@@ -0,0 +1,111 @@
1
+ ---
2
+ to: "<%= outputPaths.entity %>"
3
+ force: true
4
+ ---
5
+ import {
6
+ <%_ drizzleImports.filter(i => i !== 'relations').forEach(i => { _%>
7
+ <%= i %>,
8
+ <%_ }) _%>
9
+ } from 'drizzle-orm/pg-core';
10
+ import { relations, type InferSelectModel } from 'drizzle-orm';
11
+ import { <%= leftTable %> } from '../<%= leftTable %>/<%= leftEntity %>.entity';
12
+ <%_ if (leftEntity !== rightEntity) { _%>
13
+ import { <%= rightTable %> } from '../<%= rightTable %>/<%= rightEntity %>.entity';
14
+ <%_ } _%>
15
+
16
+ // ============================================================================
17
+ // Enums
18
+ // ============================================================================
19
+ <%_ if (hasRole) { _%>
20
+
21
+ export const <%= roleEnumName %> = pgEnum('<%= name %>_role', [
22
+ <%_ roleEnumValues.forEach(v => { _%>
23
+ '<%= v %>',
24
+ <%_ }) _%>
25
+ ]);
26
+ <%_ } _%>
27
+ <%_ processedCustomFields.filter(f => f.hasChoices).forEach(field => { _%>
28
+
29
+ export const <%= field.enumName %> = pgEnum('<%= name %>_<%= field.name %>', [
30
+ <%_ field.choices.forEach(c => { _%>
31
+ '<%= c %>',
32
+ <%_ }) _%>
33
+ ]);
34
+ <%_ }) _%>
35
+
36
+ // ============================================================================
37
+ // Table
38
+ // ============================================================================
39
+
40
+ export const <%= tableVarName %> = pgTable(
41
+ '<%= tableName %>',
42
+ {
43
+ // FK columns — composite primary key (no surrogate id: Q4 resolution)
44
+ <%= leftColumnCamel %>: uuid('<%= leftColumn %>').notNull().references(() => <%= leftTable %>.id, { onDelete: '<%= onDeleteLeft %>' }),
45
+ <%= rightColumnCamel %>: uuid('<%= rightColumn %>').notNull().references(() => <%= rightTable %>.id, { onDelete: '<%= onDeleteRight %>' }),
46
+ <%_ if (hasRole) { _%>
47
+
48
+ // Role enum (per-pairing; declared in junction YAML's fields.role.choices)
49
+ role: <%= roleEnumName %>('role'),
50
+ <%_ } _%>
51
+
52
+ // BaseJunctionFields — is_primary is always emitted
53
+ isPrimary: boolean('is_primary').notNull().default(false),
54
+ <%_ if (temporal) { _%>
55
+
56
+ // Temporal window (temporal: true, default)
57
+ startedAt: timestamp('started_at'),
58
+ endedAt: timestamp('ended_at'),
59
+ <%_ } _%>
60
+ <%_ if (sourced) { _%>
61
+
62
+ // Provenance (sourced: true, default)
63
+ sourcedFrom: text('sourced_from'),
64
+ confidence: numeric('confidence', { precision: 5, scale: 4 }),
65
+ matchedAt: timestamp('matched_at'),
66
+ <%_ } _%>
67
+ <%_ if (hasCustomFields) { _%>
68
+
69
+ // Custom fields
70
+ <%_ processedCustomFields.forEach(field => { _%>
71
+ <%_ if (field.hasChoices) { _%>
72
+ <%= field.camelName %>: <%= field.enumName %>('<%= field.name %>'),
73
+ <%_ } else if (field.drizzleType === 'uuid') { _%>
74
+ <%= field.camelName %>: uuid('<%= field.name %>'),
75
+ <%_ } else { _%>
76
+ <%= field.camelName %>: <%= field.drizzleType %>('<%= field.name %>'),
77
+ <%_ } _%>
78
+ <%_ }) _%>
79
+ <%_ } _%>
80
+
81
+ // Timestamps
82
+ createdAt: timestamp('created_at').notNull().defaultNow(),
83
+ updatedAt: timestamp('updated_at').notNull().defaultNow(),
84
+ },
85
+ (table) => [
86
+ // Composite primary key on the two FK columns (Q4 resolution: no surrogate id)
87
+ primaryKey({ columns: [table.<%= leftColumnCamel %>, table.<%= rightColumnCamel %>] }),
88
+ ],
89
+ );
90
+
91
+ export type <%= classNames.entity %> = InferSelectModel<typeof <%= tableVarName %>>;
92
+ export type <%= classNames.entity %>Insert = typeof <%= tableVarName %>.$inferInsert;
93
+
94
+ // ============================================================================
95
+ // Relations — extension-path metadata for db.query.X.findMany({ with: ... })
96
+ // Generated code does NOT consume these; they exist for hand-written admin
97
+ // queries and for #60's fan-out methods once they land.
98
+ // ============================================================================
99
+
100
+ export const <%= tableVarName %>Relations = relations(<%= tableVarName %>, ({ one }) => ({
101
+ <%= leftEntity %>: one(<%= leftTable %>, {
102
+ fields: [<%= tableVarName %>.<%= leftColumnCamel %>],
103
+ references: [<%= leftTable %>.id],
104
+ }),
105
+ <%_ if (leftEntity !== rightEntity) { _%>
106
+ <%= rightEntity %>: one(<%= rightTable %>, {
107
+ fields: [<%= tableVarName %>.<%= rightColumnCamel %>],
108
+ references: [<%= rightTable %>.id],
109
+ }),
110
+ <%_ } _%>
111
+ }));