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