@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pattern-stack/codegen",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Entity-driven code generation for full-stack TypeScript applications",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -62,7 +62,9 @@
62
62
  "chalk": "^5.6.2",
63
63
  "clipanion": "^4.0.0-rc.4",
64
64
  "drizzle-orm": "^0.45.2",
65
+ "glob": "^13.0.6",
65
66
  "ora": "^9.3.0",
67
+ "pluralize": "^8.0.0",
66
68
  "yaml": "^2.8.3",
67
69
  "zod": "^3.22.4"
68
70
  },
@@ -95,15 +97,22 @@
95
97
  "optional": true
96
98
  }
97
99
  },
100
+ "workspaces": [
101
+ "packages/*"
102
+ ],
98
103
  "devDependencies": {
99
104
  "@cubejs-client/core": "^1.0.0",
100
105
  "@nestjs/common": "10",
101
106
  "@nestjs/core": "10",
107
+ "@nestjs/testing": "^10",
102
108
  "@types/bun": "latest",
109
+ "@types/ejs": "^3.1.5",
103
110
  "@types/node": "^25.6.0",
111
+ "@types/pluralize": "^0.0.33",
104
112
  "bullmq": "^5.0.0",
105
113
  "class-transformer": "^0.5.1",
106
114
  "class-validator": "^0.14.0",
115
+ "ejs": "^5.0.2",
107
116
  "hygen": "^6.2.11",
108
117
  "ioredis": "^5.3.0",
109
118
  "reflect-metadata": "^0.2.2",
@@ -21,15 +21,27 @@ import { <%= repositoryToken %> } from '<%= imports.constants %>';
21
21
  import type { Create<%= className %>Input, I<%= className %>Repository } from '<%= imports.domain %>';
22
22
  import { <%= className %> } from '<%= imports.domain %>';
23
23
  import type { Create<%= className %>Dto } from '<%= imports.schemas %>';
24
+ <% if (hasEmits && createEventType) { -%>
25
+ import { TYPED_EVENT_BUS, TypedEventBus } from '<%= eventsTokenImport %>';
26
+ import { DRIZZLE } from '<%= drizzleTokenImport %>';
27
+ import type { DrizzleClient } from '<%= drizzleTypeImport %>';
28
+ <% } -%>
24
29
 
25
30
  @Injectable()
26
31
  export class <%= createCommandClass %> {
27
32
  constructor(
28
33
  @Inject(<%= repositoryToken %>)
29
34
  private readonly <%= camelName %>Repository: I<%= className %>Repository,
35
+ <% if (hasEmits && createEventType) { -%>
36
+ @Inject(TYPED_EVENT_BUS) private readonly typedEvents: TypedEventBus,
37
+ @Inject(DRIZZLE) private readonly db: DrizzleClient,
38
+ <% } -%>
30
39
  ) {}
31
40
 
32
- async execute(dto: Create<%= className %>Dto): Promise<<%= className %>> {
41
+ async execute(
42
+ dto: Create<%= className %>Dto,
43
+ <%= hasEmits && createEventType ? 'opts' : '_opts' %>?: { actor?: { tenantId?: string | null; userId?: string } },
44
+ ): Promise<<%= className %>> {
33
45
  // TODO: Add pre-create validation and business rules here
34
46
 
35
47
  // Map DTO to domain input
@@ -45,11 +57,36 @@ export class <%= createCommandClass %> {
45
57
  <% }) -%>
46
58
  };
47
59
 
60
+ <% if (hasEmits && createEventType) { -%>
61
+ return this.db.transaction(async (tx) => {
62
+ const entity = await this.<%= camelName %>Repository.create(input, tx);
63
+ // TODO: verify payload mapping against events/<%= createEventType.type %>.yaml
64
+ await this.typedEvents.publish(
65
+ '<%= createEventType.type %>',
66
+ entity.id,
67
+ {
68
+ <% createEventType.payloadMap.forEach((p) => { -%>
69
+ <%= p.camelKey %>: <%- p.expression %>,<% if (p.todo) { %> // TODO: <%= p.todo %><% } %>
70
+
71
+ <% }) -%>
72
+ },
73
+ {
74
+ tx,
75
+ metadata: opts?.actor
76
+ ? { tenantId: opts.actor.tenantId, userId: opts.actor.userId }
77
+ : undefined,
78
+ },
79
+ );
80
+ // TODO: Add post-create side effects here (non-event hooks, notifications, etc.)
81
+ return entity;
82
+ });
83
+ <% } else { -%>
48
84
  const created = await this.<%= camelName %>Repository.create(input);
49
85
 
50
86
  // TODO: Add post-create side effects here (events, notifications, etc.)
51
87
 
52
88
  return created;
89
+ <% } -%>
53
90
  }
54
91
  }
55
92
  <% } -%>
@@ -20,18 +20,57 @@ import { Inject, Injectable, NotFoundException } from '@nestjs/common';
20
20
  import { <%= repositoryToken %> } from '<%= imports.constants %>';
21
21
  import type { I<%= className %>Repository } from '<%= imports.domain %>';
22
22
  import { <%= className %> } from '<%= imports.domain %>';
23
+ <% if (hasEmits && deleteEventType) { -%>
24
+ import { TYPED_EVENT_BUS, TypedEventBus } from '<%= eventsTokenImport %>';
25
+ import { DRIZZLE } from '<%= drizzleTokenImport %>';
26
+ import type { DrizzleClient } from '<%= drizzleTypeImport %>';
27
+ <% } -%>
23
28
 
24
29
  @Injectable()
25
30
  export class <%= deleteCommandClass %> {
26
31
  constructor(
27
32
  @Inject(<%= repositoryToken %>)
28
33
  private readonly <%= camelName %>Repository: I<%= className %>Repository,
34
+ <% if (hasEmits && deleteEventType) { -%>
35
+ @Inject(TYPED_EVENT_BUS) private readonly typedEvents: TypedEventBus,
36
+ @Inject(DRIZZLE) private readonly db: DrizzleClient,
37
+ <% } -%>
29
38
  ) {}
30
39
 
31
- async execute(id: string): Promise<<%= className %>> {
40
+ async execute(
41
+ id: string,
42
+ <%= hasEmits && deleteEventType ? 'opts' : '_opts' %>?: { actor?: { tenantId?: string | null; userId?: string } },
43
+ ): Promise<<%= className %>> {
32
44
  // TODO: Add pre-delete validation here
33
45
  // e.g., check for dependent records, verify user permissions
34
46
 
47
+ <% if (hasEmits && deleteEventType) { -%>
48
+ return this.db.transaction(async (tx) => {
49
+ const entity = await this.<%= camelName %>Repository.delete(id, tx);
50
+ if (!entity) {
51
+ throw new NotFoundException(`<%= className %> with id ${id} not found`);
52
+ }
53
+ // TODO: verify payload mapping against events/<%= deleteEventType.type %>.yaml
54
+ await this.typedEvents.publish(
55
+ '<%= deleteEventType.type %>',
56
+ entity.id,
57
+ {
58
+ <% deleteEventType.payloadMap.forEach((p) => { -%>
59
+ <%= p.camelKey %>: <%- p.expression %>,<% if (p.todo) { %> // TODO: <%= p.todo %><% } %>
60
+
61
+ <% }) -%>
62
+ },
63
+ {
64
+ tx,
65
+ metadata: opts?.actor
66
+ ? { tenantId: opts.actor.tenantId, userId: opts.actor.userId }
67
+ : undefined,
68
+ },
69
+ );
70
+ // TODO: Add post-delete side effects here (cleanup, etc.)
71
+ return entity;
72
+ });
73
+ <% } else { -%>
35
74
  const deleted = await this.<%= camelName %>Repository.delete(id);
36
75
  if (!deleted) {
37
76
  throw new NotFoundException(`<%= className %> with id ${id} not found`);
@@ -40,6 +79,7 @@ export class <%= deleteCommandClass %> {
40
79
  // TODO: Add post-delete side effects here (events, cleanup, etc.)
41
80
 
42
81
  return deleted;
82
+ <% } -%>
43
83
  }
44
84
  }
45
85
  <% } -%>
@@ -21,15 +21,28 @@ import { <%= repositoryToken %> } from '<%= imports.constants %>';
21
21
  import type { I<%= className %>Repository, Update<%= className %>Input } from '<%= imports.domain %>';
22
22
  import { <%= className %> } from '<%= imports.domain %>';
23
23
  import type { Update<%= className %>Dto } from '<%= imports.schemas %>';
24
+ <% if (hasEmits && updateEventType) { -%>
25
+ import { TYPED_EVENT_BUS, TypedEventBus } from '<%= eventsTokenImport %>';
26
+ import { DRIZZLE } from '<%= drizzleTokenImport %>';
27
+ import type { DrizzleClient } from '<%= drizzleTypeImport %>';
28
+ <% } -%>
24
29
 
25
30
  @Injectable()
26
31
  export class <%= updateCommandClass %> {
27
32
  constructor(
28
33
  @Inject(<%= repositoryToken %>)
29
34
  private readonly <%= camelName %>Repository: I<%= className %>Repository,
35
+ <% if (hasEmits && updateEventType) { -%>
36
+ @Inject(TYPED_EVENT_BUS) private readonly typedEvents: TypedEventBus,
37
+ @Inject(DRIZZLE) private readonly db: DrizzleClient,
38
+ <% } -%>
30
39
  ) {}
31
40
 
32
- async execute(id: string, dto: Update<%= className %>Dto): Promise<<%= className %>> {
41
+ async execute(
42
+ id: string,
43
+ dto: Update<%= className %>Dto,
44
+ <%= hasEmits && updateEventType ? 'opts' : '_opts' %>?: { actor?: { tenantId?: string | null; userId?: string } },
45
+ ): Promise<<%= className %>> {
33
46
  const existing = await this.<%= camelName %>Repository.findById(id);
34
47
  if (!existing) {
35
48
  throw new NotFoundException(`<%= className %> with id ${id} not found`);
@@ -45,6 +58,33 @@ export class <%= updateCommandClass %> {
45
58
  <% }) -%>
46
59
  };
47
60
 
61
+ <% if (hasEmits && updateEventType) { -%>
62
+ return this.db.transaction(async (tx) => {
63
+ const entity = await this.<%= camelName %>Repository.update(id, input, tx);
64
+ if (!entity) {
65
+ throw new NotFoundException(`<%= className %> with id ${id} not found`);
66
+ }
67
+ // TODO: verify payload mapping against events/<%= updateEventType.type %>.yaml
68
+ await this.typedEvents.publish(
69
+ '<%= updateEventType.type %>',
70
+ entity.id,
71
+ {
72
+ <% updateEventType.payloadMap.forEach((p) => { -%>
73
+ <%= p.camelKey %>: <%- p.expression %>,<% if (p.todo) { %> // TODO: <%= p.todo %><% } %>
74
+
75
+ <% }) -%>
76
+ },
77
+ {
78
+ tx,
79
+ metadata: opts?.actor
80
+ ? { tenantId: opts.actor.tenantId, userId: opts.actor.userId }
81
+ : undefined,
82
+ },
83
+ );
84
+ // TODO: Add post-update side effects here (non-event hooks, cache invalidation, etc.)
85
+ return entity;
86
+ });
87
+ <% } else { -%>
48
88
  const updated = await this.<%= camelName %>Repository.update(id, input);
49
89
  if (!updated) {
50
90
  throw new NotFoundException(`<%= className %> with id ${id} not found`);
@@ -53,6 +93,7 @@ export class <%= updateCommandClass %> {
53
93
  // TODO: Add post-update side effects here (events, cache invalidation, etc.)
54
94
 
55
95
  return updated;
96
+ <% } -%>
56
97
  }
57
98
  }
58
99
  <% } -%>
@@ -184,6 +184,9 @@ import { DRIZZLE } from '<%= imports.repositoryToConstants %>';
184
184
  <% if (hasEntityRefFields) { -%>
185
185
  import type { EntityType } from '<%= locations.dbSchemaServer.import %>';
186
186
  <% } -%>
187
+ <% if (hasEmits && (createEventType || updateEventType || deleteEventType)) { -%>
188
+ import type { DrizzleTransaction } from '<%= eventsTokenImport %>';
189
+ <% } -%>
187
190
  import type {
188
191
  Create<%= className %>Input,
189
192
  I<%= className %>Repository,
@@ -209,11 +212,16 @@ import { <%= plural %> } from '<%= locations.dbSchemaServer.import %>';
209
212
  export class <%= className %>Repository implements I<%= className %>Repository {
210
213
  constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
211
214
 
212
- async create(input: Create<%= className %>Input): Promise<<%= className %>> {
215
+ async create(input: Create<%= className %>Input<%= (hasEmits && createEventType) ? ', tx?: DrizzleTransaction' : '' %>): Promise<<%= className %>> {
213
216
  <% if (hasTimestamps) { -%>
214
217
  const now = new Date();
215
218
  <% } -%>
219
+ <% if (hasEmits && createEventType) { -%>
220
+ const runner = tx ?? this.db;
221
+ const result = await runner
222
+ <% } else { -%>
216
223
  const result = await this.db
224
+ <% } -%>
217
225
  .insert(<%= plural %>)
218
226
  .values({
219
227
  <% fields.forEach((field) => { -%>
@@ -261,8 +269,13 @@ export class <%= className %>Repository implements I<%= className %>Repository {
261
269
  <% } -%>
262
270
  }
263
271
 
264
- async update(id: string, input: Update<%= className %>Input): Promise<<%= className %> | null> {
272
+ async update(id: string, input: Update<%= className %>Input<%= (hasEmits && updateEventType) ? ', tx?: DrizzleTransaction' : '' %>): Promise<<%= className %> | null> {
273
+ <% if (hasEmits && updateEventType) { -%>
274
+ const runner = tx ?? this.db;
275
+ const result = await runner
276
+ <% } else { -%>
265
277
  const result = await this.db
278
+ <% } -%>
266
279
  .update(<%= plural %>)
267
280
  .set({
268
281
  ...input,
@@ -277,7 +290,23 @@ export class <%= className %>Repository implements I<%= className %>Repository {
277
290
  return record ? <%= className %>.fromRecord(record) : null;
278
291
  }
279
292
 
280
- async delete(id: string): Promise<<%= className %> | null> {
293
+ async delete(id: string<%= (hasEmits && deleteEventType) ? ', tx?: DrizzleTransaction' : '' %>): Promise<<%= className %> | null> {
294
+ <% if (hasEmits && deleteEventType) { -%>
295
+ const runner = tx ?? this.db;
296
+ <% if (hasSoftDelete) { -%>
297
+ // Soft delete - set deletedAt timestamp
298
+ const result = await runner
299
+ .update(<%= plural %>)
300
+ .set({ deletedAt: new Date() })
301
+ .where(eq(<%= plural %>.id, id))
302
+ .returning();
303
+ <% } else { -%>
304
+ const result = await runner
305
+ .delete(<%= plural %>)
306
+ .where(eq(<%= plural %>.id, id))
307
+ .returning();
308
+ <% } -%>
309
+ <% } else { -%>
281
310
  <% if (hasSoftDelete) { -%>
282
311
  // Soft delete - set deletedAt timestamp
283
312
  const result = await this.db
@@ -290,6 +319,7 @@ export class <%= className %>Repository implements I<%= className %>Repository {
290
319
  .delete(<%= plural %>)
291
320
  .where(eq(<%= plural %>.id, id))
292
321
  .returning();
322
+ <% } -%>
293
323
  <% } -%>
294
324
 
295
325
  const record = result[0];
@@ -13,6 +13,9 @@ import type { <%= className %> } from './<%= name %>.entity';
13
13
  <% if (hasEntityRefFields) { -%>
14
14
  import type { EntityType } from '<%= locations.dbSchemaServer.import %>';
15
15
  <% } -%>
16
+ <% if (hasEmits && (createEventType || updateEventType || deleteEventType)) { -%>
17
+ import type { DrizzleTransaction } from '<%= eventsTokenImport %>';
18
+ <% } -%>
16
19
  <% if (hasRelationships) { -%>
17
20
 
18
21
  /**
@@ -44,11 +47,11 @@ export type Create<%= className %>Input = {
44
47
  export type Update<%= className %>Input = Partial<Create<%= className %>Input>;
45
48
 
46
49
  export interface I<%= className %>Repository {
47
- create(input: Create<%= className %>Input): Promise<<%= className %>>;
50
+ create(input: Create<%= className %>Input<%= (hasEmits && createEventType) ? ', tx?: DrizzleTransaction' : '' %>): Promise<<%= className %>>;
48
51
  findById(id: string<%= hasRelationships ? `, include?: ${className}With` : '' %>): Promise<<%= className %> | null>;
49
52
  findAll(<%= hasRelationships ? `include?: ${className}With` : '' %>): Promise<<%= className %>[]>;
50
- update(id: string, input: Update<%= className %>Input): Promise<<%= className %> | null>;
51
- delete(id: string): Promise<<%= className %> | null>;
53
+ update(id: string, input: Update<%= className %>Input<%= (hasEmits && updateEventType) ? ', tx?: DrizzleTransaction' : '' %>): Promise<<%= className %> | null>;
54
+ delete(id: string<%= (hasEmits && deleteEventType) ? ', tx?: DrizzleTransaction' : '' %>): Promise<<%= className %> | null>;
52
55
  <% if (hasSoftDelete) { -%>
53
56
  // Soft delete recovery methods
54
57
  restore(id: string): Promise<<%= className %> | null>;
@@ -6,6 +6,12 @@ force: true
6
6
  /**
7
7
  * <%= classNamePlural %> Module
8
8
  * Generated by entity codegen - do not edit directly
9
+ <% if (hasEmits) { -%>
10
+ *
11
+ * EVT-7: This entity emits typed domain events. Use-cases depend on
12
+ * TYPED_EVENT_BUS + DRIZZLE. Ensure EventsModule is registered in the
13
+ * root AppModule (global) so these tokens resolve at runtime.
14
+ <% } -%>
9
15
  */
10
16
 
11
17
  import { Module } from '@nestjs/common';
@@ -14,6 +14,7 @@ import {
14
14
  Controller,
15
15
  Delete,
16
16
  Get,
17
+ Headers,
17
18
  Param,
18
19
  ParseUUIDPipe,
19
20
  Post,
@@ -75,21 +76,31 @@ export class <%= classNamePlural %>Controller {
75
76
 
76
77
  @Post()
77
78
  @UsePipes(new ZodValidationPipe(create<%= className %>Schema))
78
- async create(@Body() dto: Create<%= className %>Dto): Promise<<%= className %>> {
79
- return this.create<%= className %>Command.execute(dto);
79
+ async create(
80
+ @Body() dto: Create<%= className %>Dto,
81
+ @Headers('x-tenant-id') tenantId?: string,
82
+ @Headers('x-user-id') userId?: string,
83
+ ): Promise<<%= className %>> {
84
+ return this.create<%= className %>Command.execute(dto, { actor: { tenantId, userId } });
80
85
  }
81
86
 
82
87
  @Put(':id')
83
88
  async update(
84
89
  @Param('id', ParseUUIDPipe) id: string,
85
90
  @Body(new ZodValidationPipe(update<%= className %>Schema)) dto: Update<%= className %>Dto,
91
+ @Headers('x-tenant-id') tenantId?: string,
92
+ @Headers('x-user-id') userId?: string,
86
93
  ): Promise<<%= className %>> {
87
- return this.update<%= className %>Command.execute(id, dto);
94
+ return this.update<%= className %>Command.execute(id, dto, { actor: { tenantId, userId } });
88
95
  }
89
96
 
90
97
  @Delete(':id')
91
- async delete(@Param('id', ParseUUIDPipe) id: string): Promise<<%= className %>> {
92
- return this.delete<%= className %>Command.execute(id);
98
+ async delete(
99
+ @Param('id', ParseUUIDPipe) id: string,
100
+ @Headers('x-tenant-id') tenantId?: string,
101
+ @Headers('x-user-id') userId?: string,
102
+ ): Promise<<%= className %>> {
103
+ return this.delete<%= className %>Command.execute(id, { actor: { tenantId, userId } });
93
104
  }
94
105
  <% if (hasRelationships) { -%>
95
106
 
@@ -120,6 +131,7 @@ import {
120
131
  Get,
121
132
  Body,
122
133
  Delete,
134
+ Headers,
123
135
  Param,
124
136
  ParseUUIDPipe,
125
137
  Post,
@@ -181,21 +193,31 @@ export class <%= classNamePlural %>Controller {
181
193
 
182
194
  @Post()
183
195
  @UsePipes(new ZodValidationPipe(create<%= className %>Schema))
184
- async create(@Body() dto: Create<%= className %>Dto): Promise<<%= className %>> {
185
- return this.create<%= className %>Command.execute(dto);
196
+ async create(
197
+ @Body() dto: Create<%= className %>Dto,
198
+ @Headers('x-tenant-id') tenantId?: string,
199
+ @Headers('x-user-id') userId?: string,
200
+ ): Promise<<%= className %>> {
201
+ return this.create<%= className %>Command.execute(dto, { actor: { tenantId, userId } });
186
202
  }
187
203
 
188
204
  @Put(':id')
189
205
  async update(
190
206
  @Param('id', ParseUUIDPipe) id: string,
191
207
  @Body(new ZodValidationPipe(update<%= className %>Schema)) dto: Update<%= className %>Dto,
208
+ @Headers('x-tenant-id') tenantId?: string,
209
+ @Headers('x-user-id') userId?: string,
192
210
  ): Promise<<%= className %>> {
193
- return this.update<%= className %>Command.execute(id, dto);
211
+ return this.update<%= className %>Command.execute(id, dto, { actor: { tenantId, userId } });
194
212
  }
195
213
 
196
214
  @Delete(':id')
197
- async delete(@Param('id', ParseUUIDPipe) id: string): Promise<<%= className %>> {
198
- return this.delete<%= className %>Command.execute(id);
215
+ async delete(
216
+ @Param('id', ParseUUIDPipe) id: string,
217
+ @Headers('x-tenant-id') tenantId?: string,
218
+ @Headers('x-user-id') userId?: string,
219
+ ): Promise<<%= className %>> {
220
+ return this.delete<%= className %>Command.execute(id, { actor: { tenantId, userId } });
199
221
  }
200
222
  }
201
223
  <% } -%>
@@ -3,11 +3,24 @@ to: "<%= typeof clpOutputPaths !== 'undefined' ? clpOutputPaths.controller : nul
3
3
  skip_if: "<%= typeof clpOutputPaths === 'undefined' %>"
4
4
  force: true
5
5
  ---
6
- import { Controller, Get, Param } from '@nestjs/common';
6
+ import { Controller, Get<% if (generateWrites) { %>, Post, Patch, Delete, Body, Headers<% } %>, NotFoundException, Param, ParseUUIDPipe } from '@nestjs/common';
7
7
  import { <%= classNames.findByIdUseCase %> } from './use-cases/find-<%= entityName %>-by-id.use-case';
8
8
  import { <%= classNames.listUseCase %> } from './use-cases/list-<%= entityNamePlural %>.use-case';
9
+ <% if (eavEnabled) { -%>
10
+ import { <%= classNames.findByIdWithFieldsUseCase %> } from './use-cases/find-<%= entityName %>-by-id-with-fields.use-case';
11
+ import { <%= classNames.listWithFieldsUseCase %> } from './use-cases/list-<%= entityNamePlural %>-with-fields.use-case';
12
+ <% } -%>
13
+ <% if (generateWrites) { -%>
14
+ import { ZodValidationPipe } from '@shared/pipes/zod-validation.pipe';
15
+ import { <%= classNames.createUseCase %> } from './use-cases/create-<%= entityName %>.use-case';
16
+ import { <%= classNames.updateUseCase %> } from './use-cases/update-<%= entityName %>.use-case';
17
+ import { <%= classNames.deleteUseCase %> } from './use-cases/delete-<%= entityName %>.use-case';
18
+ import { <%= classNames.createSchema %> } from './dto/create-<%= entityName %>.dto';
19
+ import type { <%= classNames.createDto %> } from './dto/create-<%= entityName %>.dto';
20
+ import { <%= classNames.updateSchema %> } from './dto/update-<%= entityName %>.dto';
21
+ import type { <%= classNames.updateDto %> } from './dto/update-<%= entityName %>.dto';
22
+ <% } -%>
9
23
  import type { <%= classNames.entity %> } from './<%= entityName %>.entity';
10
- // Write use cases must be hand-written. Import them here when ready.
11
24
 
12
25
  @Controller('<%= entityNamePlural %>')
13
26
  export class <%= classNames.controller %> {
@@ -15,23 +28,71 @@ export class <%= classNames.controller %> {
15
28
  // All routes go through use cases (ADR-003 — no controller → service shortcuts)
16
29
  private readonly findByIdUseCase: <%= classNames.findByIdUseCase %>,
17
30
  private readonly listUseCase: <%= classNames.listUseCase %>,
18
- // TODO: inject hand-written write use cases here
31
+ <% if (eavEnabled) { -%>
32
+ private readonly findByIdWithFieldsUseCase: <%= classNames.findByIdWithFieldsUseCase %>,
33
+ private readonly listWithFieldsUseCase: <%= classNames.listWithFieldsUseCase %>,
34
+ <% } -%>
35
+ <% if (generateWrites) { -%>
36
+ private readonly createUseCase: <%= classNames.createUseCase %>,
37
+ private readonly updateUseCase: <%= classNames.updateUseCase %>,
38
+ private readonly deleteUseCase: <%= classNames.deleteUseCase %>,
39
+ <% } -%>
19
40
  ) {}
20
41
 
21
42
  @Get()
22
43
  async getAll(): Promise<<%= classNames.entity %>[]> {
23
44
  return this.listUseCase.execute();
24
45
  }
25
-
46
+ <% if (eavEnabled) { %>
47
+ @Get('with-fields')
48
+ async getAllWithFields(): Promise<Array<<%= classNames.entity %> & { fields: Record<string, unknown> }>> {
49
+ return this.listWithFieldsUseCase.execute();
50
+ }
51
+ <% } %>
26
52
  @Get(':id')
27
- async getById(@Param('id') id: string): Promise<<%= classNames.entity %> | null> {
53
+ async getById(@Param('id', ParseUUIDPipe) id: string): Promise<<%= classNames.entity %>> {
54
+ // Use case throws NotFoundException on null/undefined (D2)
28
55
  return this.findByIdUseCase.execute(id);
29
56
  }
57
+ <% if (eavEnabled) { %>
58
+ @Get(':id/with-fields')
59
+ async getByIdWithFields(
60
+ @Param('id', ParseUUIDPipe) id: string,
61
+ ): Promise<<%= classNames.entity %> & { fields: Record<string, unknown> }> {
62
+ const entity = await this.findByIdWithFieldsUseCase.execute(id);
63
+ if (!entity) throw new NotFoundException(`<%= classNames.entity %> ${id} not found`);
64
+ return entity;
65
+ }
66
+ <% } %>
67
+ <% if (generateWrites) { %>
68
+ @Post()
69
+ async create(
70
+ @Body(new ZodValidationPipe(<%= classNames.createSchema %>)) dto: <%= classNames.createDto %>,
71
+ @Headers('x-tenant-id') tenantId?: string,
72
+ @Headers('x-user-id') userId?: string,
73
+ ): Promise<<%= classNames.entity %>> {
74
+ return this.createUseCase.execute(dto, { actor: { tenantId, userId } });
75
+ }
30
76
 
31
- // TODO: Add write routes. Each must call a hand-written use case, not the service.
32
- // Example:
33
- // @Post()
34
- // async create(@Body() dto: Create<%= classNames.entity %>Dto): Promise<<%= classNames.entity %>> {
35
- // return this.createUseCase.execute(dto);
36
- // }
77
+ @Patch(':id')
78
+ async update(
79
+ @Param('id', ParseUUIDPipe) id: string,
80
+ @Body(new ZodValidationPipe(<%= classNames.updateSchema %>)) dto: <%= classNames.updateDto %>,
81
+ @Headers('x-tenant-id') tenantId?: string,
82
+ @Headers('x-user-id') userId?: string,
83
+ ): Promise<<%= classNames.entity %>> {
84
+ const entity = await this.updateUseCase.execute(id, dto, { actor: { tenantId, userId } });
85
+ if (!entity) throw new NotFoundException(`<%= classNames.entity %> ${id} not found`);
86
+ return entity;
87
+ }
88
+
89
+ @Delete(':id')
90
+ async remove(
91
+ @Param('id', ParseUUIDPipe) id: string,
92
+ @Headers('x-tenant-id') tenantId?: string,
93
+ @Headers('x-user-id') userId?: string,
94
+ ): Promise<void> {
95
+ return this.deleteUseCase.execute(id, { actor: { tenantId, userId } });
96
+ }
97
+ <% } %>
37
98
  }
@@ -14,7 +14,9 @@ import { relations, type InferSelectModel } from 'drizzle-orm';
14
14
  import { type InferSelectModel } from 'drizzle-orm';
15
15
  <%_ } _%>
16
16
  <%_ clpBelongsTo.forEach(rel => { _%>
17
+ <%_ if (rel.relatedTable !== entityNamePlural) { _%>
17
18
  import { <%= rel.relatedTable %> } from '<%= rel.importPath %>';
19
+ <%_ } _%>
18
20
  <%_ }) _%>
19
21
 
20
22
  export const <%= entityNamePlural %> = pgTable(
@@ -22,11 +24,23 @@ export const <%= entityNamePlural %> = pgTable(
22
24
  {
23
25
  id: uuid('id').primaryKey().defaultRandom(),
24
26
  <%_ clpBelongsTo.forEach(rel => { _%>
25
- <%= rel.camelField %>: uuid('<%= rel.field %>')<%= rel.nullable ? '' : '.notNull()' %>,
27
+ <%_ if (hasSoftDelete) { _%>
28
+ // WARNING: on_delete: '<%= rel.onDeleteYaml %>' is a no-op when this entity uses soft_delete.
29
+ // BaseService.delete() issues UPDATE … SET deleted_at = now(), not DELETE, so Postgres
30
+ // cascade rules never fire for a soft-deleted parent. This FK constraint only applies on
31
+ // hard-delete (e.g. admin purge). See ADR-021: docs/adrs/ADR-021-on-delete-semantics.md
32
+ <%_ } _%>
33
+ <%= rel.camelField %>: uuid('<%= rel.field %>')<%= rel.nullable ? '' : '.notNull()' %>.references(() => <%= rel.relatedTable %>.id, { onDelete: '<%= rel.onDelete %>' }),
26
34
  <%_ }) _%>
27
35
  <%_ clpProcessedFields.forEach(field => { _%>
28
36
  <%= field.camelName %>: <%- field.drizzleChain %>,
29
37
  <%_ }) _%>
38
+ <%_ if (hasExternalIdTracking) { _%>
39
+ // external_id_tracking behavior
40
+ externalId: varchar('external_id'),
41
+ provider: varchar('provider'),
42
+ providerMetadata: jsonb('provider_metadata'),
43
+ <%_ } _%>
30
44
  <%_ if (hasTimestamps) { _%>
31
45
  createdAt: timestamp('created_at').notNull().defaultNow(),
32
46
  updatedAt: timestamp('updated_at').notNull().defaultNow(),
@@ -40,7 +54,7 @@ export const <%= entityNamePlural %> = pgTable(
40
54
 
41
55
  export const <%= entityNamePlural %>Relations = relations(<%= entityNamePlural %>, ({ one }) => ({
42
56
  <%_ clpBelongsTo.forEach(rel => { _%>
43
- <%= rel.relatedEntity %>: one(<%= rel.relatedTable %>, {
57
+ <%= rel.relationKey %>: one(<%= rel.relatedTable %>, {
44
58
  fields: [<%= entityNamePlural %>.<%= rel.camelField %>],
45
59
  references: [<%= rel.relatedTable %>.id],
46
60
  }),
@@ -1,5 +1,5 @@
1
1
  ---
2
- to: "<%= typeof clpOutputPaths !== 'undefined' ? `modules/${entityNamePlural}/index.ts` : null %>"
2
+ to: "<%= typeof clpOutputPaths !== 'undefined' ? clpOutputPaths.index : null %>"
3
3
  skip_if: "<%= typeof clpOutputPaths === 'undefined' %>"
4
4
  force: true
5
5
  ---