@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.
- package/CHANGELOG.md +60 -0
- package/dist/runtime/subsystems/index.d.ts +7 -3
- package/dist/runtime/subsystems/index.js +993 -19
- package/dist/runtime/subsystems/index.js.map +1 -1
- package/dist/runtime/subsystems/integration/entity-change-source-registry.memory.d.ts +25 -0
- package/dist/runtime/subsystems/integration/entity-change-source-registry.memory.js +34 -0
- package/dist/runtime/subsystems/integration/entity-change-source-registry.memory.js.map +1 -0
- package/dist/runtime/subsystems/integration/entity-change-source-registry.protocol.d.ts +53 -0
- package/dist/runtime/subsystems/integration/entity-change-source-registry.protocol.js +13 -0
- package/dist/runtime/subsystems/integration/entity-change-source-registry.protocol.js.map +1 -0
- package/dist/runtime/subsystems/integration/execute-integration.use-case.js.map +1 -1
- package/dist/runtime/subsystems/integration/index.d.ts +3 -1
- package/dist/runtime/subsystems/integration/index.js +35 -0
- package/dist/runtime/subsystems/integration/index.js.map +1 -1
- package/dist/runtime/subsystems/integration/integration-cursor-store.drizzle-backend.js.map +1 -1
- package/dist/runtime/subsystems/integration/integration-run-recorder.drizzle-backend.js.map +1 -1
- package/dist/runtime/subsystems/integration/integration.module.js.map +1 -1
- package/dist/runtime/subsystems/integration/integration.tokens.d.ts +14 -1
- package/dist/runtime/subsystems/integration/integration.tokens.js +2 -0
- package/dist/runtime/subsystems/integration/integration.tokens.js.map +1 -1
- package/dist/runtime/subsystems/observability/index.js.map +1 -1
- package/dist/runtime/subsystems/observability/observability.module.js.map +1 -1
- package/dist/runtime/subsystems/observability/observability.service.js.map +1 -1
- package/dist/src/cli/index.js +1074 -107
- package/dist/src/cli/index.js.map +1 -1
- package/dist/src/index.d.ts +48 -0
- package/dist/src/index.js +99 -3
- package/dist/src/index.js.map +1 -1
- package/package.json +9 -1
- package/runtime/subsystems/index.ts +15 -0
- package/runtime/subsystems/integration/entity-change-source-registry.memory.ts +40 -0
- package/runtime/subsystems/integration/entity-change-source-registry.protocol.ts +59 -0
- package/runtime/subsystems/integration/index.ts +9 -0
- package/runtime/subsystems/integration/integration.tokens.ts +14 -0
- package/templates/entity/new/clean-lite-ps/entity.ejs.t +12 -3
- package/templates/entity/new/clean-lite-ps/prompt-extension.js +212 -29
- 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.
|
|
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
|
-
<%_
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
//
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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: `${
|
|
1056
|
-
repository: `${
|
|
1057
|
-
service: `${
|
|
1058
|
-
controller: `${
|
|
1059
|
-
module: `${
|
|
1060
|
-
index: `${
|
|
1061
|
-
findByIdUseCase: `${
|
|
1062
|
-
listUseCase: `${
|
|
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
|
-
? `${
|
|
1231
|
+
? `${moduleGroupDir}/${entityNamePlural}/use-cases/find-${entityName}-by-id-with-fields.use-case.ts`
|
|
1065
1232
|
: null,
|
|
1066
1233
|
listWithFieldsUseCase: eavEnabled
|
|
1067
|
-
? `${
|
|
1234
|
+
? `${moduleGroupDir}/${entityNamePlural}/use-cases/list-${entityNamePlural}-with-fields.use-case.ts`
|
|
1068
1235
|
: null,
|
|
1069
1236
|
createUseCase: generateWrites
|
|
1070
|
-
? `${
|
|
1237
|
+
? `${moduleGroupDir}/${entityNamePlural}/use-cases/create-${entityName}.use-case.ts`
|
|
1071
1238
|
: null,
|
|
1072
1239
|
updateUseCase: generateWrites
|
|
1073
|
-
? `${
|
|
1240
|
+
? `${moduleGroupDir}/${entityNamePlural}/use-cases/update-${entityName}.use-case.ts`
|
|
1074
1241
|
: null,
|
|
1075
1242
|
deleteUseCase: generateWrites
|
|
1076
|
-
? `${
|
|
1243
|
+
? `${moduleGroupDir}/${entityNamePlural}/use-cases/delete-${entityName}.use-case.ts`
|
|
1077
1244
|
: null,
|
|
1078
|
-
createDto: `${
|
|
1079
|
-
updateDto: `${
|
|
1080
|
-
outputDto: `${
|
|
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
|
-
? `${
|
|
1249
|
+
? `${moduleGroupDir}/${entityNamePlural}/use-cases/search-${entityNamePlural}.use-case.ts`
|
|
1083
1250
|
: null,
|
|
1084
1251
|
searchController: searchQueryResolved
|
|
1085
|
-
? `${
|
|
1252
|
+
? `${moduleGroupDir}/${entityNamePlural}/${entityName}-search.controller.ts`
|
|
1086
1253
|
: null,
|
|
1087
1254
|
declarativeQueries: hasDeclarativeQueries
|
|
1088
|
-
? `${
|
|
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
|
-
|
|
1093
|
-
|
|
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];
|