@pattern-stack/codegen 0.11.0 → 0.12.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 (37) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/runtime/subsystems/index.d.ts +7 -3
  3. package/dist/runtime/subsystems/index.js +993 -19
  4. package/dist/runtime/subsystems/index.js.map +1 -1
  5. package/dist/runtime/subsystems/integration/entity-change-source-registry.memory.d.ts +25 -0
  6. package/dist/runtime/subsystems/integration/entity-change-source-registry.memory.js +34 -0
  7. package/dist/runtime/subsystems/integration/entity-change-source-registry.memory.js.map +1 -0
  8. package/dist/runtime/subsystems/integration/entity-change-source-registry.protocol.d.ts +53 -0
  9. package/dist/runtime/subsystems/integration/entity-change-source-registry.protocol.js +13 -0
  10. package/dist/runtime/subsystems/integration/entity-change-source-registry.protocol.js.map +1 -0
  11. package/dist/runtime/subsystems/integration/execute-integration.use-case.js.map +1 -1
  12. package/dist/runtime/subsystems/integration/index.d.ts +3 -1
  13. package/dist/runtime/subsystems/integration/index.js +35 -0
  14. package/dist/runtime/subsystems/integration/index.js.map +1 -1
  15. package/dist/runtime/subsystems/integration/integration-cursor-store.drizzle-backend.js.map +1 -1
  16. package/dist/runtime/subsystems/integration/integration-run-recorder.drizzle-backend.js.map +1 -1
  17. package/dist/runtime/subsystems/integration/integration.module.js.map +1 -1
  18. package/dist/runtime/subsystems/integration/integration.tokens.d.ts +14 -1
  19. package/dist/runtime/subsystems/integration/integration.tokens.js +2 -0
  20. package/dist/runtime/subsystems/integration/integration.tokens.js.map +1 -1
  21. package/dist/runtime/subsystems/observability/index.js.map +1 -1
  22. package/dist/runtime/subsystems/observability/observability.module.js.map +1 -1
  23. package/dist/runtime/subsystems/observability/observability.service.js.map +1 -1
  24. package/dist/src/cli/index.js +1074 -107
  25. package/dist/src/cli/index.js.map +1 -1
  26. package/dist/src/index.d.ts +48 -0
  27. package/dist/src/index.js +99 -3
  28. package/dist/src/index.js.map +1 -1
  29. package/package.json +9 -1
  30. package/runtime/subsystems/index.ts +15 -0
  31. package/runtime/subsystems/integration/entity-change-source-registry.memory.ts +40 -0
  32. package/runtime/subsystems/integration/entity-change-source-registry.protocol.ts +59 -0
  33. package/runtime/subsystems/integration/index.ts +9 -0
  34. package/runtime/subsystems/integration/integration.tokens.ts +14 -0
  35. package/templates/entity/new/clean-lite-ps/entity.ejs.t +12 -3
  36. package/templates/entity/new/clean-lite-ps/prompt-extension.js +212 -29
  37. package/templates/entity/new/backend/modules/core/integration-source.providers.ejs.t +0 -18
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pattern-stack/codegen",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Entity-driven code generation for full-stack TypeScript applications",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -27,6 +27,10 @@
27
27
  "types": "./dist/src/index.d.ts",
28
28
  "default": "./dist/src/index.js"
29
29
  },
30
+ "./subsystems": {
31
+ "types": "./dist/runtime/subsystems/index.d.ts",
32
+ "default": "./dist/runtime/subsystems/index.js"
33
+ },
30
34
  "./runtime/*": {
31
35
  "types": "./dist/runtime/*.d.ts",
32
36
  "default": "./dist/runtime/*.js"
@@ -127,6 +131,10 @@
127
131
  ],
128
132
  "devDependencies": {
129
133
  "@anatine/zod-openapi": "^2.2.8",
134
+ "@pattern-stack/codegen-calendar": "workspace:*",
135
+ "@pattern-stack/codegen-crm": "workspace:*",
136
+ "@pattern-stack/codegen-mail": "workspace:*",
137
+ "@pattern-stack/codegen-transcript": "workspace:*",
130
138
  "@cubejs-client/core": "^1.0.0",
131
139
  "@nestjs/common": "10",
132
140
  "@nestjs/core": "10",
@@ -50,6 +50,21 @@ export type {
50
50
  CursorSnapshot,
51
51
  } from './observability';
52
52
 
53
+ // Integration — entity change-source registry (C7) + change-source port.
54
+ // Exposed here so L2 surface packages (e.g. @pattern-stack/codegen-crm) can
55
+ // import them across the package boundary via @pattern-stack/codegen/subsystems
56
+ // (Track C C6). Selective re-export (not `export *`) to avoid the
57
+ // IntegrationRunSummary name clash with the observability barrel above.
58
+ export {
59
+ ENTITY_CHANGE_SOURCE_REGISTRY,
60
+ MemoryEntityChangeSourceRegistry,
61
+ UnknownEntityError,
62
+ } from './integration';
63
+ export type {
64
+ IEntityChangeSourceRegistry,
65
+ IChangeSource,
66
+ } from './integration';
67
+
53
68
  // Auth
54
69
  export {
55
70
  ENCRYPTION_KEY,
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Integration subsystem — in-memory entity-change-source registry
3
+ *
4
+ * Default `IEntityChangeSourceRegistry` backed by a `Map<entityName, source>`.
5
+ * Track D's codegen-emitted aggregator folds per-provider adapter
6
+ * contributions into one of these and binds it under
7
+ * `ENTITY_CHANGE_SOURCE_REGISTRY` (RFC-0001 §3); tests and simple consumers
8
+ * construct it directly.
9
+ *
10
+ * See {@link ./entity-change-source-registry.protocol} for the contract and
11
+ * #336 for scope.
12
+ */
13
+
14
+ import type { IChangeSource } from './integration-change-source.protocol';
15
+ import {
16
+ type IEntityChangeSourceRegistry,
17
+ UnknownEntityError,
18
+ } from './entity-change-source-registry.protocol';
19
+
20
+ export class MemoryEntityChangeSourceRegistry
21
+ implements IEntityChangeSourceRegistry
22
+ {
23
+ constructor(private readonly sources: Map<string, IChangeSource<unknown>>) {}
24
+
25
+ get<T = unknown>(name: string): IChangeSource<T> {
26
+ const source = this.sources.get(name);
27
+ if (!source) {
28
+ throw new UnknownEntityError(name, [...this.sources.keys()]);
29
+ }
30
+ return source as IChangeSource<T>;
31
+ }
32
+
33
+ has(name: string): boolean {
34
+ return this.sources.has(name);
35
+ }
36
+
37
+ entities(): readonly string[] {
38
+ return [...this.sources.keys()];
39
+ }
40
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Integration subsystem — entity-keyed change-source registry (port)
3
+ *
4
+ * `IEntityChangeSourceRegistry` resolves an `IChangeSource<T>` by entity name.
5
+ * It generalizes today's per-entity DI tokens (`ACCOUNT_POLL_FETCH_REGISTRY`,
6
+ * `CONTACT_POLL_FETCH_REGISTRY`, …) into one entity-keyed registry, so the L3
7
+ * composing port (`<Surface>Port`, Track C C6) can be entity-agnostic at the
8
+ * type level instead of enumerating entities (epic #328 locked decision #5).
9
+ *
10
+ * This lives in L1 (the integration subsystem) rather than in a per-surface
11
+ * package because the same shape applies across surfaces — CRM (`account`,
12
+ * `contact`, `deal`), Mail (`email`, `thread`, `label`), Transcript
13
+ * (`transcript`, `speaker`, `utterance`), Meeting (`meeting`, `attendee`).
14
+ * Cross-surface plumbing belongs at L1 (epic #328 locked decision #6).
15
+ *
16
+ * Scope (Track C · C7): this is purely the L1 type + memory impl. Codegen does
17
+ * NOT yet emit this registry, and the existing per-entity tokens keep emitting
18
+ * unchanged — the retarget (and the per-entity-token deprecation) is Track D
19
+ * D3/D4 (RFC-0001 §3/§8).
20
+ *
21
+ * See #336 (this issue), #328 (parent epic), RFC-0001 §3 (the registry
22
+ * contract Track D emits the wiring for).
23
+ */
24
+
25
+ import type { IChangeSource } from './integration-change-source.protocol';
26
+
27
+ /**
28
+ * Entity-keyed resolver for change sources. The orchestrator (and the L3
29
+ * surface port) consume this, agnostic to whether a source came from a
30
+ * hand-written adapter or a configured `PollChangeSource<T>`.
31
+ */
32
+ export interface IEntityChangeSourceRegistry {
33
+ /**
34
+ * Resolve a change source for a given entity name.
35
+ * Throws {@link UnknownEntityError} if the entity isn't registered.
36
+ */
37
+ get<T = unknown>(entityName: string): IChangeSource<T>;
38
+
39
+ /** True if the entity is registered. */
40
+ has(entityName: string): boolean;
41
+
42
+ /** List all entity names this registry serves. */
43
+ entities(): readonly string[];
44
+ }
45
+
46
+ /**
47
+ * Thrown by {@link IEntityChangeSourceRegistry.get} when no source is
48
+ * registered for the requested entity. The message enumerates the available
49
+ * entities so a misconfiguration (typo'd entity name, missing adapter
50
+ * contribution) is diagnosable from the error alone.
51
+ */
52
+ export class UnknownEntityError extends Error {
53
+ constructor(entity: string, available: readonly string[]) {
54
+ super(
55
+ `No change source registered for entity '${entity}'. Available: ${available.join(', ')}`,
56
+ );
57
+ this.name = 'UnknownEntityError';
58
+ }
59
+ }
@@ -44,6 +44,14 @@ export type {
44
44
  } from './integration-run-recorder.protocol';
45
45
  export type { ILoopbackFingerprintStore } from './integration-loopback.protocol';
46
46
 
47
+ // Entity-keyed change-source registry (C7, #336) — L1 protocol + memory impl.
48
+ // Generalizes per-entity `<ENTITY>_POLL_FETCH_REGISTRY` tokens into one
49
+ // entity-keyed registry so the L3 surface port (C6) is entity-agnostic. Codegen
50
+ // retarget to emit it is Track D D3/D4 (RFC-0001 §3).
51
+ export type { IEntityChangeSourceRegistry } from './entity-change-source-registry.protocol';
52
+ export { UnknownEntityError } from './entity-change-source-registry.protocol';
53
+ export { MemoryEntityChangeSourceRegistry } from './entity-change-source-registry.memory';
54
+
47
55
  // DetectionConfig (#226-1) — Zod schema + inferred types; canonical source
48
56
  // of filter/mapping shape consumed by primitives + codegen YAML validator
49
57
  export {
@@ -100,6 +108,7 @@ export { buildChangeSource } from './build-change-source';
100
108
 
101
109
  // Tokens
102
110
  export {
111
+ ENTITY_CHANGE_SOURCE_REGISTRY,
103
112
  INTEGRATION_CHANGE_SOURCE,
104
113
  INTEGRATION_CURSOR_STORE,
105
114
  INTEGRATION_FIELD_DIFFER,
@@ -47,3 +47,17 @@ export const INTEGRATION_MODULE_OPTIONS = 'INTEGRATION_MODULE_OPTIONS' as const;
47
47
  * Consumed by `ExecuteIntegrationUseCase` to enforce the tenantId-is-required rule.
48
48
  */
49
49
  export const INTEGRATION_MULTI_TENANT = 'INTEGRATION_MULTI_TENANT' as const;
50
+
51
+ /**
52
+ * Injection token for the entity-keyed `IEntityChangeSourceRegistry` (C7,
53
+ * #336). Bound to the codegen-emitted aggregator that folds per-provider
54
+ * adapter contributions into one registry (RFC-0001 §3, emitted by Track D
55
+ * D3/D4).
56
+ *
57
+ * A string constant, not `Symbol.for(...)`, to match this subsystem's token
58
+ * convention (see file header). The originating issue's code block proposed a
59
+ * `Symbol.for('@pattern-stack/codegen.entity-change-source-registry')` key,
60
+ * predating the sync→integration consolidation onto string tokens; kept as a
61
+ * string here for internal consistency with the other INTEGRATION_* tokens.
62
+ */
63
+ export const ENTITY_CHANGE_SOURCE_REGISTRY = 'ENTITY_CHANGE_SOURCE_REGISTRY' as const;
@@ -22,6 +22,10 @@ import { type InferSelectModel } from 'drizzle-orm';
22
22
  import { <%= rel.relatedTable %> } from '<%= rel.importPath %>';
23
23
  <%_ } _%>
24
24
  <%_ }) _%>
25
+ <%_ /* #354: field-level foreign_key target table imports */ _%>
26
+ <%_ if (typeof clpFieldFkImports !== 'undefined') { clpFieldFkImports.forEach(imp => { _%>
27
+ import { <%= imp.relatedTable %> } from '<%= imp.importPath %>';
28
+ <%_ }) } _%>
25
29
  <%_ /* CGP-358b: import has_many target tables for many() relation const */ _%>
26
30
  <%_ if (typeof clpExistingHasMany !== 'undefined') { _%>
27
31
  <%_ clpExistingHasMany.filter(rel => !rel.isSelfRef).forEach(rel => { _%>
@@ -65,10 +69,15 @@ export const <%= entityNamePlural %> = pgTable(
65
69
  deletedAt: timestamp('deleted_at'),
66
70
  <%_ } _%>
67
71
  },
68
- <%_ if (hasExternalIdTracking) { _%>
72
+ <%_ /* #355/#356: pgTable extra-config — indexes + composite unique indexes + external_id unique index */ _%>
73
+ <%_ if (typeof clpTableConstraints !== 'undefined' && clpTableConstraints.length > 0) { _%>
69
74
  (t) => [
70
- // external_id_tracking behavior ON CONFLICT target for integrationUpsert
71
- uniqueIndex('uq_<%= entityNamePlural %>_provider_external_id').on(t.provider, t.externalId),
75
+ <%_ clpTableConstraints.forEach(c => { _%>
76
+ <%_ if (c.comment) { _%>
77
+ // <%= c.comment %>
78
+ <%_ } _%>
79
+ <%- c.expr %>,
80
+ <%_ }) _%>
72
81
  ],
73
82
  <%_ } _%>
74
83
  );
@@ -145,6 +145,7 @@ const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1);
145
145
  const camelCase = (s) => s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
146
146
  const pascalCase = (s) => capitalize(camelCase(s));
147
147
  const pluralize = (s) => pluralizePkg.plural(s);
148
+ const singularize = (s) => pluralizePkg.singular(s);
148
149
 
149
150
  // ============================================================================
150
151
  // Drizzle type mapping
@@ -260,15 +261,45 @@ function buildDrizzleChain(fieldName, field, drizzleType, enumName) {
260
261
  chain += '.notNull()';
261
262
  }
262
263
 
263
- // Boolean defaults
264
- if (drizzleType === 'boolean' && hasDefault) {
265
- chain += `.default(${field.default})`;
264
+ // Column defaults (#345). The value is validated upstream (schema `default:`).
265
+ // Covers every scalar type, enum literals, and the `now` sentinel on
266
+ // timestamp/date columns — not just booleans.
267
+ if (hasDefault) {
268
+ chain += renderColumnDefault(field.default, drizzleType);
266
269
  }
267
270
 
268
- // Timestamp defaults for datetime fields in behavior context handled separately
269
271
  return chain;
270
272
  }
271
273
 
274
+ /**
275
+ * Render a Drizzle `.default(...)` (or `.defaultNow()`) suffix for a column.
276
+ *
277
+ * - timestamp/date + a `now`/`now()`/`current_timestamp` sentinel → `.defaultNow()`
278
+ * - numeric (Drizzle returns it as a string) → quoted, even for numeric YAML values
279
+ * - string / enum literal → single-quoted, escaped
280
+ * - number / boolean → bare literal
281
+ * - anything else (jsonb object/array default) → JSON literal
282
+ */
283
+ function renderColumnDefault(value, drizzleType) {
284
+ if (
285
+ (drizzleType === 'timestamp' || drizzleType === 'date') &&
286
+ typeof value === 'string' &&
287
+ /^(now|now\(\)|current_timestamp)$/i.test(value)
288
+ ) {
289
+ return '.defaultNow()';
290
+ }
291
+ if (drizzleType === 'numeric') {
292
+ return `.default('${String(value)}')`;
293
+ }
294
+ if (typeof value === 'number' || typeof value === 'boolean') {
295
+ return `.default(${value})`;
296
+ }
297
+ if (typeof value === 'string') {
298
+ return `.default('${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}')`;
299
+ }
300
+ return `.default(${JSON.stringify(value)})`;
301
+ }
302
+
272
303
  /**
273
304
  * Process entity fields into ProcessedField[]
274
305
  */
@@ -446,10 +477,95 @@ function processBelongsTo(relationships, parentEntityNamePlural) {
446
477
  return result;
447
478
  }
448
479
 
480
+ /**
481
+ * Field-level `foreign_key:` + `index:` emission (#354, #355).
482
+ *
483
+ * Distinct from `relationships:`-driven belongs_to FKs (processBelongsTo),
484
+ * which own their own column + import. This handles features declared
485
+ * directly on a column field:
486
+ *
487
+ * - `foreign_key: <table>.<column>` → append `.references(() => <table>.<column>)`
488
+ * to the column's Drizzle chain (self-FKs get the `: AnyPgColumn` annotation)
489
+ * and record the cross-module import. The table segment is the Drizzle table
490
+ * export name (plural, e.g. `conversations`); the import path singularizes it
491
+ * to the entity file (`../conversations/conversation.entity`).
492
+ * - `index: true` → emit a named single-column index in the pgTable
493
+ * extra-config callback (`<table>_<column>_idx`).
494
+ *
495
+ * Mutates each processed field's `drizzleChain` in place (the same objects are
496
+ * later rendered via clpProcessedFields). Returns the imports + index
497
+ * expressions the template needs.
498
+ *
499
+ * @param {object[]} renderedFields the fields actually emitted as columns
500
+ * (nonFkFields — belongs_to FK columns excluded)
501
+ * @param {object} fields raw field map keyed by snake_case name
502
+ * @param {string} entityNamePlural Drizzle table export name for self-FK detection
503
+ */
504
+ function processFieldFeatures(renderedFields, fields, entityNamePlural) {
505
+ const fkImports = [];
506
+ const indexExpressions = [];
507
+ const seenImports = new Set();
508
+ let hasSelfFieldFk = false;
509
+
510
+ for (const pf of renderedFields) {
511
+ const field = fields[pf.name];
512
+ if (!field) continue;
513
+
514
+ // --- foreign_key (#354) ---
515
+ if (typeof field.foreign_key === 'string' && field.foreign_key.includes('.')) {
516
+ const [relatedTable, fkColumn] = field.foreign_key.split('.');
517
+ const isSelfFk = relatedTable === entityNamePlural;
518
+ pf.drizzleChain += isSelfFk
519
+ ? `.references((): AnyPgColumn => ${relatedTable}.${fkColumn})`
520
+ : `.references(() => ${relatedTable}.${fkColumn})`;
521
+
522
+ if (isSelfFk) {
523
+ hasSelfFieldFk = true;
524
+ } else if (!seenImports.has(relatedTable)) {
525
+ seenImports.add(relatedTable);
526
+ fkImports.push({
527
+ relatedTable,
528
+ importPath: `../${relatedTable}/${singularize(relatedTable)}.entity`,
529
+ });
530
+ }
531
+ }
532
+
533
+ // --- index: true (#355) ---
534
+ if (field.index === true) {
535
+ indexExpressions.push({
536
+ comment: null,
537
+ expr: `index('${entityNamePlural}_${pf.name}_idx').on(t.${pf.camelName})`,
538
+ });
539
+ }
540
+ }
541
+
542
+ return { fkImports, indexExpressions, hasSelfFieldFk };
543
+ }
544
+
545
+ /**
546
+ * Composite unique index emission (#356).
547
+ *
548
+ * Top-level `unique_indexes: [{ fields: [...], name? }]` → a `uniqueIndex(...)`
549
+ * entry in the pgTable extra-config callback. Column names are camelCased to
550
+ * match the emitted Drizzle column identifiers; they may reference FK columns
551
+ * (belongs_to) or ordinary fields. Index name defaults to
552
+ * `<table>_<col1>_<col2>_..._uniq`.
553
+ */
554
+ function processUniqueIndexes(uniqueIndexes, entityNamePlural) {
555
+ if (!Array.isArray(uniqueIndexes)) return [];
556
+
557
+ return uniqueIndexes.map((ui) => {
558
+ const cols = ui.fields;
559
+ const name = ui.name || `${entityNamePlural}_${cols.join('_')}_uniq`;
560
+ const onCols = cols.map((c) => `t.${camelCase(c)}`).join(', ');
561
+ return { comment: null, expr: `uniqueIndex('${name}').on(${onCols})` };
562
+ });
563
+ }
564
+
449
565
  /**
450
566
  * Collect drizzle imports needed for entity fields
451
567
  */
452
- function collectDrizzleImports(processedFields, belongsTo, hasTimestamps, hasSoftDelete, hasExternalIdTracking, hasMany = []) {
568
+ function collectDrizzleImports(processedFields, belongsTo, hasTimestamps, hasSoftDelete, hasExternalIdTracking, hasMany = [], extraImports = []) {
453
569
  const imports = new Set(['pgTable', 'uuid']);
454
570
 
455
571
  for (const field of processedFields) {
@@ -486,6 +602,13 @@ function collectDrizzleImports(processedFields, belongsTo, hasTimestamps, hasSof
486
602
  imports.add('relations');
487
603
  }
488
604
 
605
+ // Caller-supplied extras: `index` (field-level index: true) and
606
+ // `uniqueIndex` (composite unique_indexes) — see processFieldFeatures /
607
+ // processUniqueIndexes.
608
+ for (const extra of extraImports) {
609
+ imports.add(extra);
610
+ }
611
+
489
612
  return Array.from(imports).sort();
490
613
  }
491
614
 
@@ -885,6 +1008,18 @@ export function buildCleanLitePsLocals(definition, baseLocals) {
885
1008
  const entityNamePlural = entity.plural || pluralize(entityName);
886
1009
  const entityNamePluralPascal = pascalCase(entityNamePlural);
887
1010
 
1011
+ // #403: bounded-context folder grouping. A top-level `context:` nests this
1012
+ // entity's module folder under that segment so same-context entities group
1013
+ // together (`<modules>/<context>/<plural>/`); no context → flat
1014
+ // (`<modules>/<plural>/`, byte-identical to pre-#403). Emit-folder-only —
1015
+ // every intra-module import is folder-relative and therefore unaffected, and
1016
+ // the generated barrel recomputes its import paths from the full file paths
1017
+ // below. The module-folder base used by every clpOutputPaths entry:
1018
+ const entityContext = definition.context || null;
1019
+ const moduleGroupDir = entityContext
1020
+ ? `${srcRoot}/modules/${entityContext}`
1021
+ : `${srcRoot}/modules`;
1022
+
888
1023
  // Generation toggles — `generate.writes` defaults to true so consumers who
889
1024
  // regenerate pick up create/update/delete use cases without YAML changes.
890
1025
  // Set `generate.writes: false` in YAML to suppress write-side emission
@@ -1033,6 +1168,33 @@ export function buildCleanLitePsLocals(definition, baseLocals) {
1033
1168
  const fkFieldNames = new Set(belongsTo.map((r) => r.field));
1034
1169
  const nonFkFields = processedFields.filter((f) => !fkFieldNames.has(f.name));
1035
1170
 
1171
+ // Field-level foreign_key + index emission (#354, #355). Mutates the
1172
+ // drizzleChain of the rendered (non-belongs_to) columns in place. Skip FK
1173
+ // imports for tables belongs_to already imports to avoid duplicate import
1174
+ // lines.
1175
+ const fieldFeatures = processFieldFeatures(nonFkFields, fields, entityNamePlural);
1176
+ const belongsToTables = new Set(belongsTo.map((r) => r.relatedTable));
1177
+ const clpFieldFkImports = fieldFeatures.fkImports.filter(
1178
+ (imp) => !belongsToTables.has(imp.relatedTable),
1179
+ );
1180
+
1181
+ // Composite unique indexes (#356).
1182
+ const uniqueIndexExpressions = processUniqueIndexes(definition.unique_indexes, entityNamePlural);
1183
+
1184
+ // pgTable extra-config callback entries, in emission order: single-column
1185
+ // indexes, composite unique indexes, then the external_id_tracking unique
1186
+ // index (the ON CONFLICT target integrationUpsert relies on).
1187
+ const clpTableConstraints = [
1188
+ ...fieldFeatures.indexExpressions,
1189
+ ...uniqueIndexExpressions,
1190
+ ];
1191
+ if (hasExternalIdTracking) {
1192
+ clpTableConstraints.push({
1193
+ comment: 'external_id_tracking behavior — ON CONFLICT target for integrationUpsert',
1194
+ expr: `uniqueIndex('uq_${entityNamePlural}_provider_external_id').on(t.provider, t.externalId)`,
1195
+ });
1196
+ }
1197
+
1036
1198
  // Enum field declarations — surface a separate collection so the entity
1037
1199
  // template can emit `export const xEnum = pgEnum('x', [...])` ahead of
1038
1200
  // the `pgTable(...)` block. Both FK-filtered and unfiltered processing
@@ -1045,52 +1207,63 @@ export function buildCleanLitePsLocals(definition, baseLocals) {
1045
1207
  choices: f.choices,
1046
1208
  }));
1047
1209
 
1048
- // Drizzle imports needed
1049
- const drizzleEntityImports = collectDrizzleImports(processedFields, belongsTo, hasTimestamps, hasSoftDelete, hasExternalIdTracking, hasMany);
1210
+ // Drizzle imports needed. `index` / `uniqueIndex` are pulled in only when a
1211
+ // field declares `index: true` or the entity declares `unique_indexes:`
1212
+ // (external_id_tracking adds `uniqueIndex` on its own flag below).
1213
+ const extraDrizzleImports = [];
1214
+ if (fieldFeatures.indexExpressions.length > 0) extraDrizzleImports.push('index');
1215
+ if (uniqueIndexExpressions.length > 0) extraDrizzleImports.push('uniqueIndex');
1216
+ const drizzleEntityImports = collectDrizzleImports(processedFields, belongsTo, hasTimestamps, hasSoftDelete, hasExternalIdTracking, hasMany, extraDrizzleImports);
1050
1217
  // Whether relations() import is needed (CGP-358b: also trigger on has_many)
1051
1218
  const hasRelationsBlock = belongsTo.length > 0 || hasMany.length > 0;
1052
1219
 
1053
1220
  // Output paths
1054
1221
  const outputPaths = {
1055
- entity: `${srcRoot}/modules/${entityNamePlural}/${entityName}.entity.ts`,
1056
- repository: `${srcRoot}/modules/${entityNamePlural}/${entityName}.repository.ts`,
1057
- service: `${srcRoot}/modules/${entityNamePlural}/${entityName}.service.ts`,
1058
- controller: `${srcRoot}/modules/${entityNamePlural}/${entityName}.controller.ts`,
1059
- module: `${srcRoot}/modules/${entityNamePlural}/${entityNamePlural}.module.ts`,
1060
- index: `${srcRoot}/modules/${entityNamePlural}/index.ts`,
1061
- findByIdUseCase: `${srcRoot}/modules/${entityNamePlural}/use-cases/find-${entityName}-by-id.use-case.ts`,
1062
- listUseCase: `${srcRoot}/modules/${entityNamePlural}/use-cases/list-${entityNamePlural}.use-case.ts`,
1222
+ entity: `${moduleGroupDir}/${entityNamePlural}/${entityName}.entity.ts`,
1223
+ repository: `${moduleGroupDir}/${entityNamePlural}/${entityName}.repository.ts`,
1224
+ service: `${moduleGroupDir}/${entityNamePlural}/${entityName}.service.ts`,
1225
+ controller: `${moduleGroupDir}/${entityNamePlural}/${entityName}.controller.ts`,
1226
+ module: `${moduleGroupDir}/${entityNamePlural}/${entityNamePlural}.module.ts`,
1227
+ index: `${moduleGroupDir}/${entityNamePlural}/index.ts`,
1228
+ findByIdUseCase: `${moduleGroupDir}/${entityNamePlural}/use-cases/find-${entityName}-by-id.use-case.ts`,
1229
+ listUseCase: `${moduleGroupDir}/${entityNamePlural}/use-cases/list-${entityNamePlural}.use-case.ts`,
1063
1230
  findByIdWithFieldsUseCase: eavEnabled
1064
- ? `${srcRoot}/modules/${entityNamePlural}/use-cases/find-${entityName}-by-id-with-fields.use-case.ts`
1231
+ ? `${moduleGroupDir}/${entityNamePlural}/use-cases/find-${entityName}-by-id-with-fields.use-case.ts`
1065
1232
  : null,
1066
1233
  listWithFieldsUseCase: eavEnabled
1067
- ? `${srcRoot}/modules/${entityNamePlural}/use-cases/list-${entityNamePlural}-with-fields.use-case.ts`
1234
+ ? `${moduleGroupDir}/${entityNamePlural}/use-cases/list-${entityNamePlural}-with-fields.use-case.ts`
1068
1235
  : null,
1069
1236
  createUseCase: generateWrites
1070
- ? `${srcRoot}/modules/${entityNamePlural}/use-cases/create-${entityName}.use-case.ts`
1237
+ ? `${moduleGroupDir}/${entityNamePlural}/use-cases/create-${entityName}.use-case.ts`
1071
1238
  : null,
1072
1239
  updateUseCase: generateWrites
1073
- ? `${srcRoot}/modules/${entityNamePlural}/use-cases/update-${entityName}.use-case.ts`
1240
+ ? `${moduleGroupDir}/${entityNamePlural}/use-cases/update-${entityName}.use-case.ts`
1074
1241
  : null,
1075
1242
  deleteUseCase: generateWrites
1076
- ? `${srcRoot}/modules/${entityNamePlural}/use-cases/delete-${entityName}.use-case.ts`
1243
+ ? `${moduleGroupDir}/${entityNamePlural}/use-cases/delete-${entityName}.use-case.ts`
1077
1244
  : null,
1078
- createDto: `${srcRoot}/modules/${entityNamePlural}/dto/create-${entityName}.dto.ts`,
1079
- updateDto: `${srcRoot}/modules/${entityNamePlural}/dto/update-${entityName}.dto.ts`,
1080
- outputDto: `${srcRoot}/modules/${entityNamePlural}/dto/${entityName}-output.dto.ts`,
1245
+ createDto: `${moduleGroupDir}/${entityNamePlural}/dto/create-${entityName}.dto.ts`,
1246
+ updateDto: `${moduleGroupDir}/${entityNamePlural}/dto/update-${entityName}.dto.ts`,
1247
+ outputDto: `${moduleGroupDir}/${entityNamePlural}/dto/${entityName}-output.dto.ts`,
1081
1248
  searchUseCase: searchQueryResolved
1082
- ? `${srcRoot}/modules/${entityNamePlural}/use-cases/search-${entityNamePlural}.use-case.ts`
1249
+ ? `${moduleGroupDir}/${entityNamePlural}/use-cases/search-${entityNamePlural}.use-case.ts`
1083
1250
  : null,
1084
1251
  searchController: searchQueryResolved
1085
- ? `${srcRoot}/modules/${entityNamePlural}/${entityName}-search.controller.ts`
1252
+ ? `${moduleGroupDir}/${entityNamePlural}/${entityName}-search.controller.ts`
1086
1253
  : null,
1087
1254
  declarativeQueries: hasDeclarativeQueries
1088
- ? `${srcRoot}/modules/${entityNamePlural}/use-cases/declarative-queries.ts`
1255
+ ? `${moduleGroupDir}/${entityNamePlural}/use-cases/declarative-queries.ts`
1089
1256
  : null,
1090
1257
  // ADR-033.1 §8 — integration-source module emission for clean-lite-ps. Co-located
1091
1258
  // with the entity feature module under src/modules/<plural>/. Closes #267.
1092
- integrationSourceModule: `${srcRoot}/modules/${entityNamePlural}/${entityName}-integration-source.module.ts`,
1093
- integrationSourceProviders: `${srcRoot}/modules/${entityNamePlural}/${entityName}-integration-source.providers.ts`,
1259
+ // #403: routed through moduleGroupDir so a `context:`-tagged entity nests the
1260
+ // integration-source module under its context segment (untagged → flat, the
1261
+ // same `${srcRoot}/modules/<plural>/…` path as before).
1262
+ integrationSourceModule: `${moduleGroupDir}/${entityNamePlural}/${entityName}-integration-source.module.ts`,
1263
+ // ADR-033.2's per-entity provider tuples (`<entity>-integration-source.providers.ts`)
1264
+ // are removed by RFC-0001 §8 (D4). The surface-scoped typed view
1265
+ // (`src/integrations/<surface>/types.generated.ts`) is the single source of
1266
+ // provider truth now — see src/cli/shared/adapter-emission-generator.ts.
1094
1267
  };
1095
1268
 
1096
1269
  // Architecture-specific imports for clean-lite-ps. The integration-source module
@@ -1245,6 +1418,10 @@ export function buildCleanLitePsLocals(definition, baseLocals) {
1245
1418
  hasSearchQuery: !!searchQueryResolved,
1246
1419
 
1247
1420
 
1421
+ // #403: bounded-context segment (null when untagged). Drives the
1422
+ // module-folder nesting reflected in clpOutputPaths above.
1423
+ clpContext: entityContext,
1424
+
1248
1425
  // Output paths
1249
1426
  clpOutputPaths: outputPaths,
1250
1427
 
@@ -1269,9 +1446,15 @@ export function buildCleanLitePsLocals(definition, baseLocals) {
1269
1446
  // strict mode flags the table const with TS7022/TS7024 (circular initializer).
1270
1447
  // Surfaced by the cgp-62 relationship-scenario smoke when generating a CRM
1271
1448
  // account with a `parent_account_id` self-FK.
1272
- clpHasSelfFk: belongsTo.some((rel) => rel.isSelfFk),
1449
+ clpHasSelfFk: belongsTo.some((rel) => rel.isSelfFk) || fieldFeatures.hasSelfFieldFk,
1273
1450
  clpEnumFields,
1274
1451
 
1452
+ // Field-level foreign_key imports (#354) and pgTable extra-config
1453
+ // entries: single-column indexes (#355) + composite unique indexes (#356)
1454
+ // + the external_id_tracking unique index.
1455
+ clpFieldFkImports,
1456
+ clpTableConstraints,
1457
+
1275
1458
  // Declarative queries
1276
1459
  processedQueries,
1277
1460
  hasDeclarativeQueries,
@@ -1,18 +0,0 @@
1
- ---
2
- to: "<%= hasDetection ? (isCleanLitePs ? clpOutputPaths.integrationSourceProviders : `${basePaths.backendSrc}/${paths.modules}/${name}-integration-source.providers.ts`) : null %>"
3
- skip_if: <%= !hasDetection %>
4
- force: true
5
- ---
6
- <%- typeof generatedBanner !== 'undefined' ? generatedBanner : '' %>
7
- /**
8
- * <%= className %> integration-source providers
9
- * Generated by entity codegen — do not edit directly.
10
- *
11
- * ADR-033.2: typed provider artifacts. Consumers type their registry as
12
- * Record<<%= className %>Provider, …> to get compile-time provider-key checks.
13
- * Order matches YAML detection: insertion order.
14
- */
15
-
16
- export const <%= name.toUpperCase() %>_PROVIDERS = [<%- detectionProviders.map((p) => `'${p}'`).join(', ') %>] as const;
17
-
18
- export type <%= className %>Provider = (typeof <%= name.toUpperCase() %>_PROVIDERS)[number];