@specverse/engines 6.7.8 → 6.16.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/dist/ai/behavior-ai-service.js +2 -2
- package/dist/ai/behavior-ai-service.js.map +1 -1
- package/dist/inference/core/specly-converter.d.ts.map +1 -1
- package/dist/inference/core/specly-converter.js +20 -0
- package/dist/inference/core/specly-converter.js.map +1 -1
- package/dist/inference/index.d.ts.map +1 -1
- package/dist/inference/index.js +72 -22
- package/dist/inference/index.js.map +1 -1
- package/dist/inference/logical/generators/controller-generator.d.ts.map +1 -1
- package/dist/inference/logical/generators/controller-generator.js +26 -4
- package/dist/inference/logical/generators/controller-generator.js.map +1 -1
- package/dist/libs/instance-factories/applications/templates/generic/backend-package-json-generator.js +22 -5
- package/dist/libs/instance-factories/controllers/templates/fastify/routes-generator.js +50 -15
- package/dist/libs/instance-factories/controllers/templates/fastify/server-generator.js +26 -6
- package/dist/libs/instance-factories/services/postgres-native-services.yaml +90 -0
- package/dist/libs/instance-factories/services/templates/_shared/step-matching.js +44 -0
- package/dist/libs/instance-factories/services/templates/mongodb-native/controller-generator.js +68 -13
- package/dist/libs/instance-factories/services/templates/mongodb-native/step-conventions.js +515 -0
- package/dist/libs/instance-factories/services/templates/postgres-native/client-generator.js +165 -0
- package/dist/libs/instance-factories/services/templates/postgres-native/controller-generator.js +300 -0
- package/dist/libs/instance-factories/services/templates/postgres-native/ddl-generator.js +169 -0
- package/dist/libs/instance-factories/services/templates/postgres-native/service-generator.js +65 -0
- package/dist/libs/instance-factories/services/templates/postgres-native/step-conventions.js +433 -0
- package/dist/libs/instance-factories/services/templates/prisma/ai-behaviors-generator.js +27 -4
- package/dist/libs/instance-factories/services/templates/prisma/step-conventions.js +7 -34
- package/dist/parser/processors/ExecutableProcessor.d.ts.map +1 -1
- package/dist/parser/processors/ExecutableProcessor.js +14 -1
- package/dist/parser/processors/ExecutableProcessor.js.map +1 -1
- package/dist/realize/index.d.ts.map +1 -1
- package/dist/realize/index.js +30 -3
- package/dist/realize/index.js.map +1 -1
- package/libs/instance-factories/applications/templates/generic/backend-package-json-generator.ts +46 -24
- package/libs/instance-factories/controllers/templates/fastify/routes-generator.ts +80 -21
- package/libs/instance-factories/controllers/templates/fastify/server-generator.ts +48 -7
- package/libs/instance-factories/services/postgres-native-services.yaml +90 -0
- package/libs/instance-factories/services/templates/_shared/step-matching.ts +103 -0
- package/libs/instance-factories/services/templates/mongodb-native/controller-generator.ts +97 -23
- package/libs/instance-factories/services/templates/mongodb-native/step-conventions.ts +691 -0
- package/libs/instance-factories/services/templates/postgres-native/__tests__/controller-generator.test.ts +193 -0
- package/libs/instance-factories/services/templates/postgres-native/client-generator.ts +178 -0
- package/libs/instance-factories/services/templates/postgres-native/controller-generator.ts +372 -0
- package/libs/instance-factories/services/templates/postgres-native/ddl-generator.ts +236 -0
- package/libs/instance-factories/services/templates/postgres-native/service-generator.ts +84 -0
- package/libs/instance-factories/services/templates/postgres-native/step-conventions.ts +539 -0
- package/libs/instance-factories/services/templates/prisma/ai-behaviors-generator.ts +61 -7
- package/libs/instance-factories/services/templates/prisma/step-conventions.ts +21 -68
- package/package.json +4 -3
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostgreSQL Native (pg) — Controller Generator
|
|
3
|
+
*
|
|
4
|
+
* Generates per-model controllers exposing CURVED operations via the pg
|
|
5
|
+
* pool helpers (`insertOne` / `findOneByField` / `updateOneById` / etc.).
|
|
6
|
+
* Same shape and same behaviour as the mongodb-native counterpart — the
|
|
7
|
+
* only differences are runtime-driver calls.
|
|
8
|
+
*
|
|
9
|
+
* Scope (MVP):
|
|
10
|
+
* - Validate / Create / Retrieve / Update / Delete fully wired
|
|
11
|
+
* - Evolve emits a lifecycle-aware update when the model declares states;
|
|
12
|
+
* transitions are validated against the declared flow before update
|
|
13
|
+
* - Custom actions emit per-step bodies via `matchPgStep`. The same
|
|
14
|
+
* matcher is passed to the AI-behaviors-generator (via realize/index.ts)
|
|
15
|
+
* so both sides agree on `declaredVars`, function names, and inputs.
|
|
16
|
+
*
|
|
17
|
+
* Out of scope:
|
|
18
|
+
* - withTx() multi-statement transactions for create/update flows
|
|
19
|
+
* (deferred — current MVP commits each helper call individually)
|
|
20
|
+
* - JOIN-driven retrieve variants (deferred)
|
|
21
|
+
*
|
|
22
|
+
* Table naming: lowercase + 's' (User → users) unless the model declares
|
|
23
|
+
* `storage.table`. ID is a string at the API surface; the pgClient helpers
|
|
24
|
+
* pass the value through as-is so UUIDs / slugs / int-as-string all work.
|
|
25
|
+
*/
|
|
26
|
+
import type { TemplateContext } from '@specverse/types';
|
|
27
|
+
import { buildTransitionMap, isAutoField } from '@specverse/types/spec-rules';
|
|
28
|
+
|
|
29
|
+
export default function generatePgNativeController(context: TemplateContext): string {
|
|
30
|
+
const { controller, model, models } = context as any;
|
|
31
|
+
if (!controller) throw new Error('Controller is required in template context');
|
|
32
|
+
if (!model) throw new Error('Model is required for controller generation');
|
|
33
|
+
|
|
34
|
+
const controllerName = controller.name;
|
|
35
|
+
const modelName = model.name;
|
|
36
|
+
const modelVar = lowerFirst(modelName);
|
|
37
|
+
const table = tableName(model);
|
|
38
|
+
const curedOps = controller.cured || {};
|
|
39
|
+
|
|
40
|
+
// Build a name → ModelSpec registry once so step-conventions can read
|
|
41
|
+
// attribute defaults (level: 1, totalResources: '', etc.) and FK targets
|
|
42
|
+
// when emitting create/insertMany code.
|
|
43
|
+
const modelRegistry: Record<string, any> = {};
|
|
44
|
+
if (Array.isArray(models)) {
|
|
45
|
+
for (const m of models) if (m?.name) modelRegistry[m.name] = m;
|
|
46
|
+
} else if (models && typeof models === 'object') {
|
|
47
|
+
Object.assign(modelRegistry, models);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const customActions = generateCustomActions(controller, modelRegistry);
|
|
51
|
+
|
|
52
|
+
const validate = generateValidateMethod(model, modelName);
|
|
53
|
+
const create = curedOps.create ? generateCreateMethod(modelName, modelVar) : '';
|
|
54
|
+
const retrieve = curedOps.retrieve ? generateRetrieveMethod(modelName, modelVar) : '';
|
|
55
|
+
const update = curedOps.update ? generateUpdateMethod(modelName, modelVar) : '';
|
|
56
|
+
const evolve = curedOps.evolve ? generateEvolveMethod(model, modelName, modelVar) : '';
|
|
57
|
+
const del = curedOps.delete ? generateDeleteMethod(modelName, modelVar) : '';
|
|
58
|
+
|
|
59
|
+
const hasEventPublishing =
|
|
60
|
+
curedOps.create || curedOps.update || curedOps.evolve || curedOps.delete;
|
|
61
|
+
|
|
62
|
+
// Helpers always imported: insertOne / findOneByField / findAll /
|
|
63
|
+
// updateOneById / deleteOneById are referenced by the CRUD ops; `query`
|
|
64
|
+
// is referenced by retrieveAll's offset path. The composite + bulk
|
|
65
|
+
// helpers are conditional — only the convention-driven step bodies
|
|
66
|
+
// emit them, so we scan the customActions.code to decide.
|
|
67
|
+
const helperImports = [
|
|
68
|
+
'insertOne',
|
|
69
|
+
'findOneByField',
|
|
70
|
+
'findAll',
|
|
71
|
+
'updateOneById',
|
|
72
|
+
'deleteOneById',
|
|
73
|
+
'query',
|
|
74
|
+
customActions.code.includes('findOneByFields(') ? 'findOneByFields' : '',
|
|
75
|
+
customActions.code.includes('insertMany(') ? 'insertMany' : '',
|
|
76
|
+
].filter(Boolean).join(', ');
|
|
77
|
+
|
|
78
|
+
return `/**
|
|
79
|
+
* ${controllerName}
|
|
80
|
+
* Model-specific business logic for ${modelName} (PostgreSQL native driver)
|
|
81
|
+
* ${controller.description || ''}
|
|
82
|
+
*/
|
|
83
|
+
import { ${helperImports} } from '../db/pgClient.js';
|
|
84
|
+
${hasEventPublishing || customActions.needsAiBehaviors ? `import { eventBus } from '../events/eventBus.js';` : ''}
|
|
85
|
+
${customActions.needsAiBehaviors ? `import * as aiBehaviors from '../behaviors/${controllerName}.ai.js';` : ''}
|
|
86
|
+
|
|
87
|
+
const TABLE_NAME = '${table}';
|
|
88
|
+
|
|
89
|
+
export class ${controllerName} {
|
|
90
|
+
${validate}
|
|
91
|
+
${create}
|
|
92
|
+
${retrieve}
|
|
93
|
+
${update}
|
|
94
|
+
${evolve}
|
|
95
|
+
${del}
|
|
96
|
+
${customActions.code}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const ${modelVar}Controller = new ${controllerName}();
|
|
100
|
+
export default ${modelVar}Controller;
|
|
101
|
+
`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function lowerFirst(s: string): string {
|
|
105
|
+
return s.charAt(0).toLowerCase() + s.slice(1);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function tableName(model: any): string {
|
|
109
|
+
if (model?.storage?.table) return String(model.storage.table);
|
|
110
|
+
// Lowercase-pluralized: User → users, OrderItem → orderitems
|
|
111
|
+
return model.name.toLowerCase() + 's';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function generateValidateMethod(model: any, modelName: string): string {
|
|
115
|
+
return `
|
|
116
|
+
/**
|
|
117
|
+
* Validate ${modelName} data — runs before create / update / evolve.
|
|
118
|
+
*/
|
|
119
|
+
public validate(
|
|
120
|
+
_data: any,
|
|
121
|
+
_context: { operation: 'create' | 'update' | 'evolve' }
|
|
122
|
+
): { valid: boolean; errors: string[] } {
|
|
123
|
+
const errors: string[] = [];
|
|
124
|
+
${generateValidationLogic(model)}
|
|
125
|
+
return { valid: errors.length === 0, errors };
|
|
126
|
+
}
|
|
127
|
+
`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function generateValidationLogic(model: any): string {
|
|
131
|
+
if (!model.attributes) return ' // No validation rules defined';
|
|
132
|
+
const attrList = Array.isArray(model.attributes)
|
|
133
|
+
? model.attributes.map((a: any) => [a.name, a])
|
|
134
|
+
: Object.entries(model.attributes);
|
|
135
|
+
const out: string[] = [];
|
|
136
|
+
attrList.forEach(([name, attr]: [string, any]) => {
|
|
137
|
+
if (attr.required && !isAutoField(name, attr)) {
|
|
138
|
+
out.push(` if (_context.operation === 'create' && !_data.${name}) errors.push('${name} is required');`);
|
|
139
|
+
}
|
|
140
|
+
if (attr.type === 'String' || attr.type === 'string') {
|
|
141
|
+
if (attr.min) out.push(` if (_data.${name} && _data.${name}.length < ${attr.min}) errors.push('${name} must be at least ${attr.min} characters');`);
|
|
142
|
+
if (attr.max) out.push(` if (_data.${name} && _data.${name}.length > ${attr.max}) errors.push('${name} must be at most ${attr.max} characters');`);
|
|
143
|
+
}
|
|
144
|
+
if (attr.values && Array.isArray(attr.values)) {
|
|
145
|
+
const values = attr.values.map((v: string) => `'${v}'`).join(', ');
|
|
146
|
+
out.push(` if (_data.${name} && ![${values}].includes(_data.${name})) errors.push('${name} must be one of: ${attr.values.join(', ')}');`);
|
|
147
|
+
}
|
|
148
|
+
if (attr.format === 'email') {
|
|
149
|
+
out.push(` if (_data.${name} && !/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(_data.${name})) errors.push('${name} must be a valid email address');`);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
return out.join('\n') || ' // No validation rules defined';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function generateCreateMethod(modelName: string, modelVar: string): string {
|
|
156
|
+
return `
|
|
157
|
+
/**
|
|
158
|
+
* Create a new ${modelName}.
|
|
159
|
+
*/
|
|
160
|
+
public async create(data: any): Promise<any> {
|
|
161
|
+
const validation = this.validate(data, { operation: 'create' });
|
|
162
|
+
if (!validation.valid) throw new Error(\`Validation failed: \${validation.errors.join(', ')}\`);
|
|
163
|
+
|
|
164
|
+
const ${modelVar} = await insertOne(TABLE_NAME, { ...data });
|
|
165
|
+
|
|
166
|
+
await eventBus.publish('${modelName}Created', { ...${modelVar}, timestamp: new Date().toISOString() } as any);
|
|
167
|
+
return ${modelVar};
|
|
168
|
+
}
|
|
169
|
+
`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function generateRetrieveMethod(modelName: string, _modelVar: string): string {
|
|
173
|
+
return `
|
|
174
|
+
/**
|
|
175
|
+
* Retrieve ${modelName} by id. Returns null when not found.
|
|
176
|
+
*/
|
|
177
|
+
public async retrieve(id: string): Promise<any | null> {
|
|
178
|
+
return await findOneByField(TABLE_NAME, 'id', id);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Retrieve a page of ${modelName}s.
|
|
183
|
+
*/
|
|
184
|
+
public async retrieveAll(options: { skip?: number; take?: number } = {}): Promise<any[]> {
|
|
185
|
+
if (!options.skip && !options.take) {
|
|
186
|
+
return await findAll(TABLE_NAME);
|
|
187
|
+
}
|
|
188
|
+
const limit = options.take ?? 100;
|
|
189
|
+
const offset = options.skip ?? 0;
|
|
190
|
+
const result = await query(\`SELECT * FROM "\${TABLE_NAME}" LIMIT $1 OFFSET $2\`, [limit, offset]);
|
|
191
|
+
return result.rows;
|
|
192
|
+
}
|
|
193
|
+
`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function generateUpdateMethod(modelName: string, modelVar: string): string {
|
|
197
|
+
return `
|
|
198
|
+
/**
|
|
199
|
+
* Update ${modelName}.
|
|
200
|
+
*/
|
|
201
|
+
public async update(id: string, data: any): Promise<any> {
|
|
202
|
+
const validation = this.validate(data, { operation: 'update' });
|
|
203
|
+
if (!validation.valid) throw new Error(\`Validation failed: \${validation.errors.join(', ')}\`);
|
|
204
|
+
|
|
205
|
+
// Strip nested objects + id — only scalar fields are written.
|
|
206
|
+
const updateData: Record<string, unknown> = {};
|
|
207
|
+
for (const [key, value] of Object.entries(data)) {
|
|
208
|
+
if (key === 'id') continue;
|
|
209
|
+
if (Array.isArray(value)) continue;
|
|
210
|
+
if (value !== null && typeof value === 'object' && !(value instanceof Date)) continue;
|
|
211
|
+
updateData[key] = value;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
await updateOneById(TABLE_NAME, id, updateData);
|
|
215
|
+
const ${modelVar} = await findOneByField(TABLE_NAME, 'id', id);
|
|
216
|
+
if (!${modelVar}) throw new Error('${modelName} not found after update');
|
|
217
|
+
|
|
218
|
+
await eventBus.publish('${modelName}Updated', { ...${modelVar}, timestamp: new Date().toISOString() } as any);
|
|
219
|
+
return ${modelVar};
|
|
220
|
+
}
|
|
221
|
+
`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function generateEvolveMethod(model: any, modelName: string, modelVar: string): string {
|
|
225
|
+
const lifecycles = Array.isArray(model.lifecycles)
|
|
226
|
+
? model.lifecycles
|
|
227
|
+
: (model.lifecycles ? Object.entries(model.lifecycles).map(([name, lc]: [string, any]) => ({ name, ...lc })) : []);
|
|
228
|
+
const lifecycle = lifecycles[0];
|
|
229
|
+
const lifecycleName = lifecycle?.name || 'status';
|
|
230
|
+
const validTransitions = lifecycle ? buildTransitionMap(lifecycle) : {};
|
|
231
|
+
const states = lifecycle?.states && lifecycle.states.length > 0
|
|
232
|
+
? lifecycle.states.map((s: any) => typeof s === 'string' ? s : s.name)
|
|
233
|
+
: Array.from(new Set([
|
|
234
|
+
...Object.keys(validTransitions),
|
|
235
|
+
...Object.values(validTransitions).flat(),
|
|
236
|
+
]));
|
|
237
|
+
|
|
238
|
+
return `
|
|
239
|
+
/**
|
|
240
|
+
* Evolve ${modelName} through lifecycle "${lifecycleName}"
|
|
241
|
+
* States: ${states.join(' → ') || '(none declared)'}
|
|
242
|
+
*/
|
|
243
|
+
public async evolve(id: string, data: any): Promise<any> {
|
|
244
|
+
const current = await findOneByField(TABLE_NAME, 'id', id);
|
|
245
|
+
if (!current) throw new Error('${modelName} not found');
|
|
246
|
+
|
|
247
|
+
const targetLifecycle = data?.lifecycleName || '${lifecycleName}';
|
|
248
|
+
const targetState = data?.toState ?? data?.state ?? data?.[targetLifecycle];
|
|
249
|
+
if (!targetState) throw new Error('evolve requires toState (or ${lifecycleName}) in the request body');
|
|
250
|
+
|
|
251
|
+
${states.length > 0 ? `
|
|
252
|
+
const currentState = (current as any)[targetLifecycle];
|
|
253
|
+
const validTransitions: Record<string, string[]> = ${JSON.stringify(validTransitions)};
|
|
254
|
+
const allowed = validTransitions[currentState] || [];
|
|
255
|
+
if (!allowed.includes(targetState)) {
|
|
256
|
+
throw new Error(\`Invalid transition: \${currentState} → \${targetState}. Allowed: \${allowed.join(', ') || 'none'}\`);
|
|
257
|
+
}
|
|
258
|
+
` : ''}
|
|
259
|
+
|
|
260
|
+
await updateOneById(TABLE_NAME, id, { [targetLifecycle]: targetState });
|
|
261
|
+
const ${modelVar} = await findOneByField(TABLE_NAME, 'id', id);
|
|
262
|
+
if (!${modelVar}) throw new Error('${modelName} not found after evolve');
|
|
263
|
+
|
|
264
|
+
await eventBus.publish('${modelName}Evolved', { ...${modelVar}, timestamp: new Date().toISOString() } as any);
|
|
265
|
+
return ${modelVar};
|
|
266
|
+
}
|
|
267
|
+
`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function generateDeleteMethod(modelName: string, modelVar: string): string {
|
|
271
|
+
return `
|
|
272
|
+
/**
|
|
273
|
+
* Delete ${modelName}.
|
|
274
|
+
*/
|
|
275
|
+
public async delete(id: string): Promise<void> {
|
|
276
|
+
const ${modelVar} = await findOneByField(TABLE_NAME, 'id', id);
|
|
277
|
+
await deleteOneById(TABLE_NAME, id);
|
|
278
|
+
if (${modelVar}) {
|
|
279
|
+
await eventBus.publish('${modelName}Deleted', { ...${modelVar}, timestamp: new Date().toISOString() } as any);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
interface CustomActionsResult {
|
|
286
|
+
code: string;
|
|
287
|
+
needsAiBehaviors: boolean;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
import { matchPgStep, type PgStepContext } from './step-conventions.js';
|
|
291
|
+
|
|
292
|
+
function generateCustomActions(controller: any, modelRegistry: Record<string, any> = {}): CustomActionsResult {
|
|
293
|
+
if (!controller.actions || Object.keys(controller.actions).length === 0) {
|
|
294
|
+
return { code: '', needsAiBehaviors: false };
|
|
295
|
+
}
|
|
296
|
+
const CRUD_NAMES = new Set(['create', 'retrieve', 'retrieveAll', 'update', 'evolve', 'delete', 'validate']);
|
|
297
|
+
const modelName = controller.model || (controller.name || '').replace(/Controller$/, '') || 'Model';
|
|
298
|
+
const tableNameLocal = modelName.toLowerCase() + 's';
|
|
299
|
+
const out: string[] = [];
|
|
300
|
+
let needsAiBehaviors = false;
|
|
301
|
+
for (const [actionName, action] of Object.entries<any>(controller.actions)) {
|
|
302
|
+
if (CRUD_NAMES.has(actionName)) {
|
|
303
|
+
console.warn(
|
|
304
|
+
`⚠️ ${controller.name || 'Controller'}.${actionName} — behaviour-derived action collides with the auto-generated CURVED \`${actionName}\` op. Dropped to avoid TS2393 duplicate-implementation. Rename the behaviour (e.g. \`${actionName}Soft\` / \`hardDelete\`) if you need the custom logic.`
|
|
305
|
+
);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
const steps: any[] = Array.isArray(action.steps) ? action.steps : [];
|
|
309
|
+
const stepsHeader = steps.length > 0
|
|
310
|
+
? steps.map((s: any) => ` * - ${typeof s === 'string' ? s : (s.action || JSON.stringify(s))}`).join('\n')
|
|
311
|
+
: ' * (no spec steps declared)';
|
|
312
|
+
|
|
313
|
+
const declaredVars = new Set<string>();
|
|
314
|
+
const stepBodies: string[] = [];
|
|
315
|
+
let usesArgs = false;
|
|
316
|
+
let actionRefersToAi = false;
|
|
317
|
+
steps.forEach((rawStep: any, i: number) => {
|
|
318
|
+
const stepText = typeof rawStep === 'string' ? rawStep : (rawStep?.step || rawStep?.action);
|
|
319
|
+
if (typeof stepText !== 'string') {
|
|
320
|
+
stepBodies.push(` // Step ${i + 1}: (non-string step ignored)`);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const ctx: PgStepContext = {
|
|
324
|
+
modelName,
|
|
325
|
+
tableName: tableNameLocal,
|
|
326
|
+
serviceName: controller.name || 'Controller',
|
|
327
|
+
operationName: actionName,
|
|
328
|
+
stepNum: i + 1,
|
|
329
|
+
parameterNames: Object.keys(action.parameters || {}),
|
|
330
|
+
declaredVars,
|
|
331
|
+
models: modelRegistry,
|
|
332
|
+
};
|
|
333
|
+
const result = matchPgStep(stepText, ctx);
|
|
334
|
+
stepBodies.push(result.call);
|
|
335
|
+
if (/\bargs\./.test(result.call)) usesArgs = true;
|
|
336
|
+
if (!result.matched) actionRefersToAi = true;
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
if (actionRefersToAi) needsAiBehaviors = true;
|
|
340
|
+
const argsParam = usesArgs ? 'args: any = {}' : '_args: any = {}';
|
|
341
|
+
let combined = stepBodies.join('\n\n');
|
|
342
|
+
// Drop unused step{N}Result locals (strict tsc's noUnusedLocals applies
|
|
343
|
+
// even to underscored names — same logic as the mongo controller).
|
|
344
|
+
const stepResultRe = /const\s+(step\d+Result)\s*=/g;
|
|
345
|
+
let mres: RegExpExecArray | null;
|
|
346
|
+
const declared: string[] = [];
|
|
347
|
+
while ((mres = stepResultRe.exec(combined)) !== null) declared.push(mres[1]!);
|
|
348
|
+
for (const name of declared) {
|
|
349
|
+
const refCount = (combined.match(new RegExp(`\\b${name}\\b`, 'g')) || []).length;
|
|
350
|
+
if (refCount <= 1) {
|
|
351
|
+
combined = combined.replace(new RegExp(`const\\s+${name}\\s*=\\s*`), '');
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
const body = steps.length > 0
|
|
355
|
+
? combined + `\n return { success: true };`
|
|
356
|
+
: ` throw new Error('${controller.name || 'Controller'}.${actionName} is not implemented');`;
|
|
357
|
+
|
|
358
|
+
out.push(`
|
|
359
|
+
/**
|
|
360
|
+
* ${actionName}
|
|
361
|
+
* ${action.description || ''}
|
|
362
|
+
*
|
|
363
|
+
* Spec steps:
|
|
364
|
+
${stepsHeader}
|
|
365
|
+
*/
|
|
366
|
+
public async ${actionName}(${argsParam}): Promise<any> {
|
|
367
|
+
${body}
|
|
368
|
+
}
|
|
369
|
+
`);
|
|
370
|
+
}
|
|
371
|
+
return { code: out.join('\n'), needsAiBehaviors };
|
|
372
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Postgres native (pg) — schema.sql DDL generator.
|
|
3
|
+
*
|
|
4
|
+
* Emits a single `schema.sql` script with one CREATE TABLE statement per
|
|
5
|
+
* spec model, plus indexes derived from `unique` and FK declarations.
|
|
6
|
+
* Designed to be run via `npm run db:setup` (which pipes the file through
|
|
7
|
+
* `psql "$POSTGRES_URL"`). Idempotent: every CREATE is `IF NOT EXISTS`.
|
|
8
|
+
*
|
|
9
|
+
* Type mapping (best-effort, kept simple):
|
|
10
|
+
* String, UUID, Text, Email → TEXT
|
|
11
|
+
* Integer, Int → INTEGER
|
|
12
|
+
* BigInt → BIGINT
|
|
13
|
+
* Float, Number → DOUBLE PRECISION
|
|
14
|
+
* Boolean → BOOLEAN
|
|
15
|
+
* DateTime, Date → TIMESTAMPTZ
|
|
16
|
+
* Json → JSONB
|
|
17
|
+
*
|
|
18
|
+
* Primary key:
|
|
19
|
+
* - `id UUID required` → id UUID PRIMARY KEY DEFAULT gen_random_uuid()
|
|
20
|
+
* - any other "id" required → id TEXT PRIMARY KEY
|
|
21
|
+
*
|
|
22
|
+
* Out of scope (deferred): partial indexes, RLS policies, check constraints
|
|
23
|
+
* derived from `min/max` ranges, composite keys.
|
|
24
|
+
*/
|
|
25
|
+
import type { TemplateContext } from '@specverse/types';
|
|
26
|
+
|
|
27
|
+
export default function generatePgSchemaSql(context: TemplateContext): string {
|
|
28
|
+
const { spec, models } = context as any;
|
|
29
|
+
|
|
30
|
+
// Pull a flat list of {modelName, model} from the most-likely shapes:
|
|
31
|
+
// 1. Realize passes `models` (array of model specs from the resolved
|
|
32
|
+
// component); use it directly.
|
|
33
|
+
// 2. Otherwise walk spec.components[].models.
|
|
34
|
+
const allModels: Array<[string, any]> = [];
|
|
35
|
+
if (Array.isArray(models) && models.length > 0) {
|
|
36
|
+
for (const m of models) if (m?.name) allModels.push([m.name, m]);
|
|
37
|
+
} else if (spec?.components) {
|
|
38
|
+
const components = Array.isArray(spec.components)
|
|
39
|
+
? spec.components
|
|
40
|
+
: Object.values(spec.components);
|
|
41
|
+
for (const comp of components as any[]) {
|
|
42
|
+
const ms = comp?.models;
|
|
43
|
+
if (!ms) continue;
|
|
44
|
+
const entries: [string, any][] = Array.isArray(ms)
|
|
45
|
+
? ms.map((m: any) => [m.name, m])
|
|
46
|
+
: Object.entries(ms);
|
|
47
|
+
for (const [name, m] of entries) {
|
|
48
|
+
if (name) allModels.push([name, m]);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const banner = `-- ============================================================
|
|
54
|
+
-- ${spec?.metadata?.component || 'Application'} — schema.sql
|
|
55
|
+
-- Generated by @specverse/engines (postgres-native).
|
|
56
|
+
-- Run via: psql "$POSTGRES_URL" -f src/db/schema.sql
|
|
57
|
+
-- Re-runnable: every statement is IF NOT EXISTS / idempotent.
|
|
58
|
+
-- ============================================================
|
|
59
|
+
CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- for gen_random_uuid()
|
|
60
|
+
`;
|
|
61
|
+
|
|
62
|
+
if (allModels.length === 0) {
|
|
63
|
+
return banner + '\n-- No models declared. (Add one to your spec to populate this file.)\n';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const tableBlocks = allModels.map(([name, model]) => generateTable(name, model));
|
|
67
|
+
return banner + '\n' + tableBlocks.join('\n\n') + '\n';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function generateTable(modelName: string, model: any): string {
|
|
71
|
+
const table = model?.storage?.table || (modelName.toLowerCase() + 's');
|
|
72
|
+
const attrs = model.attributes;
|
|
73
|
+
if (!attrs) {
|
|
74
|
+
return `-- ${modelName}\nCREATE TABLE IF NOT EXISTS "${table}" (\n id TEXT PRIMARY KEY\n);`;
|
|
75
|
+
}
|
|
76
|
+
const list: [string, any][] = Array.isArray(attrs)
|
|
77
|
+
? attrs.map((a: any) => [a.name, a])
|
|
78
|
+
: Object.entries(attrs);
|
|
79
|
+
|
|
80
|
+
const columns: string[] = [];
|
|
81
|
+
const indexes: string[] = [];
|
|
82
|
+
let hasIdColumn = false;
|
|
83
|
+
|
|
84
|
+
for (const [colName, attr] of list) {
|
|
85
|
+
if (!colName) continue;
|
|
86
|
+
const { sql, isPk, isUnique } = columnDef(colName, attr, modelName);
|
|
87
|
+
if (isPk) hasIdColumn = true;
|
|
88
|
+
columns.push(' ' + sql);
|
|
89
|
+
if (isUnique && !isPk) {
|
|
90
|
+
indexes.push(
|
|
91
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS "${table}_${colName}_uq" ON "${table}" ("${colName}");`,
|
|
92
|
+
);
|
|
93
|
+
} else if (colName.endsWith('Id') && colName !== 'id') {
|
|
94
|
+
// FK columns — index for join performance.
|
|
95
|
+
indexes.push(
|
|
96
|
+
`CREATE INDEX IF NOT EXISTS "${table}_${colName}_idx" ON "${table}" ("${colName}");`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// No id declared in spec — synthesise one so the table is keyed.
|
|
102
|
+
if (!hasIdColumn) {
|
|
103
|
+
columns.unshift(' "id" TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// createdAt / updatedAt — provide defaults if not declared.
|
|
107
|
+
const declaredNames = new Set(list.map(([n]) => n));
|
|
108
|
+
if (!declaredNames.has('createdAt')) {
|
|
109
|
+
columns.push(' "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW()');
|
|
110
|
+
}
|
|
111
|
+
if (!declaredNames.has('updatedAt')) {
|
|
112
|
+
columns.push(' "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW()');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Lifecycle columns — for every declared lifecycle, add a TEXT column
|
|
116
|
+
// (named after the lifecycle, defaulting to its first state) so
|
|
117
|
+
// controller.evolve has somewhere to write the transitioned state.
|
|
118
|
+
// Mongo is schemaless so this happens implicitly there; in postgres we
|
|
119
|
+
// need an explicit column.
|
|
120
|
+
const lifecycles = Array.isArray(model.lifecycles)
|
|
121
|
+
? model.lifecycles
|
|
122
|
+
: (model.lifecycles ? Object.entries(model.lifecycles).map(([name, lc]: [string, any]) => ({ name, ...lc })) : []);
|
|
123
|
+
for (const lc of lifecycles) {
|
|
124
|
+
const lcName = lc?.name;
|
|
125
|
+
if (!lcName || declaredNames.has(lcName)) continue;
|
|
126
|
+
const initial = deriveInitialState(lc);
|
|
127
|
+
const defaultClause = initial ? ` DEFAULT '${initial.replace(/'/g, "''")}'` : '';
|
|
128
|
+
columns.push(` "${lcName}" TEXT NOT NULL${defaultClause}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const indexBlock = indexes.length > 0 ? '\n\n' + indexes.join('\n') : '';
|
|
132
|
+
return `-- ${modelName}\nCREATE TABLE IF NOT EXISTS "${table}" (\n${columns.join(',\n')}\n);${indexBlock}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
interface ColumnDefResult {
|
|
136
|
+
sql: string;
|
|
137
|
+
isPk: boolean;
|
|
138
|
+
isUnique: boolean;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function columnDef(name: string, attr: any, _modelName: string): ColumnDefResult {
|
|
142
|
+
const required = !!attr.required;
|
|
143
|
+
const unique = !!attr.unique;
|
|
144
|
+
const type = (attr.type || 'String').toLowerCase();
|
|
145
|
+
const isIdColumn = name === 'id';
|
|
146
|
+
|
|
147
|
+
if (isIdColumn) {
|
|
148
|
+
if (type === 'uuid') {
|
|
149
|
+
return { sql: `"id" UUID PRIMARY KEY DEFAULT gen_random_uuid()`, isPk: true, isUnique: true };
|
|
150
|
+
}
|
|
151
|
+
return { sql: `"id" TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text`, isPk: true, isUnique: true };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let sqlType: string;
|
|
155
|
+
switch (type) {
|
|
156
|
+
case 'integer':
|
|
157
|
+
case 'int':
|
|
158
|
+
sqlType = 'INTEGER';
|
|
159
|
+
break;
|
|
160
|
+
case 'bigint':
|
|
161
|
+
sqlType = 'BIGINT';
|
|
162
|
+
break;
|
|
163
|
+
case 'float':
|
|
164
|
+
case 'number':
|
|
165
|
+
case 'double':
|
|
166
|
+
sqlType = 'DOUBLE PRECISION';
|
|
167
|
+
break;
|
|
168
|
+
case 'boolean':
|
|
169
|
+
case 'bool':
|
|
170
|
+
sqlType = 'BOOLEAN';
|
|
171
|
+
break;
|
|
172
|
+
case 'datetime':
|
|
173
|
+
case 'date':
|
|
174
|
+
case 'timestamp':
|
|
175
|
+
sqlType = 'TIMESTAMPTZ';
|
|
176
|
+
break;
|
|
177
|
+
case 'uuid':
|
|
178
|
+
sqlType = 'UUID';
|
|
179
|
+
break;
|
|
180
|
+
case 'json':
|
|
181
|
+
case 'jsonb':
|
|
182
|
+
sqlType = 'JSONB';
|
|
183
|
+
break;
|
|
184
|
+
default:
|
|
185
|
+
sqlType = 'TEXT';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const nullness = required ? ' NOT NULL' : '';
|
|
189
|
+
const uniqueClause = unique ? ' UNIQUE' : '';
|
|
190
|
+
const defaultClause = formatColumnDefault(attr.default, type);
|
|
191
|
+
return {
|
|
192
|
+
sql: `"${name}" ${sqlType}${nullness}${uniqueClause}${defaultClause}`,
|
|
193
|
+
isPk: false,
|
|
194
|
+
isUnique: unique,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Derive the initial state from a lifecycle declaration. Handles:
|
|
199
|
+
* - explicit `initial: stateName`
|
|
200
|
+
* - explicit states[] array — first entry
|
|
201
|
+
* - `flow: "a -> b -> c"` shorthand — first arrow-source
|
|
202
|
+
*/
|
|
203
|
+
function deriveInitialState(lc: any): string | null {
|
|
204
|
+
if (!lc) return null;
|
|
205
|
+
if (typeof lc.initial === 'string') return lc.initial;
|
|
206
|
+
if (Array.isArray(lc.states) && lc.states.length > 0) {
|
|
207
|
+
const first = lc.states[0];
|
|
208
|
+
return typeof first === 'string' ? first : (first?.name ?? null);
|
|
209
|
+
}
|
|
210
|
+
if (typeof lc.flow === 'string') {
|
|
211
|
+
const head = lc.flow.split(/\s*->\s*/)[0]?.trim();
|
|
212
|
+
return head || null;
|
|
213
|
+
}
|
|
214
|
+
if (lc.transitions && typeof lc.transitions === 'object') {
|
|
215
|
+
// Pick the source side of the first transition.
|
|
216
|
+
const first = Object.values(lc.transitions)[0];
|
|
217
|
+
if (typeof first === 'string') return first.split(/\s*->\s*/)[0]?.trim() || null;
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function formatColumnDefault(value: any, type: string): string {
|
|
223
|
+
if (value === undefined || value === null) return '';
|
|
224
|
+
if (typeof value === 'boolean') return ` DEFAULT ${value}`;
|
|
225
|
+
if (typeof value === 'number') return ` DEFAULT ${value}`;
|
|
226
|
+
if (typeof value === 'string') {
|
|
227
|
+
if (/^-?\d+(\.\d+)?$/.test(value)) return ` DEFAULT ${value}`;
|
|
228
|
+
if (value === 'true' || value === 'false') return ` DEFAULT ${value}`;
|
|
229
|
+
if (value === 'now' && (type === 'datetime' || type === 'date' || type === 'timestamp')) {
|
|
230
|
+
return ` DEFAULT NOW()`;
|
|
231
|
+
}
|
|
232
|
+
// Quoted SQL string literal — escape single quotes.
|
|
233
|
+
return ` DEFAULT '${value.replace(/'/g, "''")}'`;
|
|
234
|
+
}
|
|
235
|
+
return '';
|
|
236
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostgreSQL Native (pg) — Service Generator
|
|
3
|
+
*
|
|
4
|
+
* Generates abstract business-logic services that use the pgClient
|
|
5
|
+
* helpers directly. The service body is intentionally skeletal: each
|
|
6
|
+
* declared operation gets a typed method, but the body is a TODO stub
|
|
7
|
+
* containing the spec steps as comments. The behavior-step → SQL
|
|
8
|
+
* conventions for service bodies are a follow-up; controllers already
|
|
9
|
+
* inline conventions today.
|
|
10
|
+
*/
|
|
11
|
+
import type { TemplateContext } from '@specverse/types';
|
|
12
|
+
|
|
13
|
+
export default function generatePgNativeService(context: TemplateContext): string {
|
|
14
|
+
const { service } = context;
|
|
15
|
+
if (!service) throw new Error('Service is required in template context');
|
|
16
|
+
|
|
17
|
+
const serviceName = service.name;
|
|
18
|
+
const operationsCode = generateOperations(service);
|
|
19
|
+
const usesDb = /\bquery\b|\bgetPool\b/.test(operationsCode);
|
|
20
|
+
const hasEvents = (service.publishes && service.publishes.length > 0) ||
|
|
21
|
+
(service.subscribes && service.subscribes.length > 0);
|
|
22
|
+
|
|
23
|
+
return `/**
|
|
24
|
+
* ${serviceName}
|
|
25
|
+
* Abstract business logic service (PostgreSQL native driver)
|
|
26
|
+
* ${service.description || ''}
|
|
27
|
+
*/
|
|
28
|
+
${usesDb ? `import { query, getPool } from '../db/pgClient.js';` : ''}
|
|
29
|
+
${hasEvents ? `import { eventBus } from '../events/eventBus.js';` : ''}
|
|
30
|
+
|
|
31
|
+
export class ${serviceName} {
|
|
32
|
+
${operationsCode}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const ${lowerFirst(serviceName)} = new ${serviceName}();
|
|
36
|
+
export const ${lowerFirst(serviceName)}Instance = ${lowerFirst(serviceName)};
|
|
37
|
+
export default ${lowerFirst(serviceName)};
|
|
38
|
+
`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function lowerFirst(s: string): string {
|
|
42
|
+
return s.charAt(0).toLowerCase() + s.slice(1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function generateOperations(service: any): string {
|
|
46
|
+
const ops = service.operations;
|
|
47
|
+
if (!ops || (Array.isArray(ops) && ops.length === 0) || (!Array.isArray(ops) && Object.keys(ops).length === 0)) {
|
|
48
|
+
return `
|
|
49
|
+
/**
|
|
50
|
+
* Default service entrypoint — replace with real operations once declared
|
|
51
|
+
* on the service's spec.
|
|
52
|
+
*/
|
|
53
|
+
public async execute(_params: any = {}): Promise<any> {
|
|
54
|
+
throw new Error('${service.name}.execute is not implemented');
|
|
55
|
+
}
|
|
56
|
+
`;
|
|
57
|
+
}
|
|
58
|
+
const entries: [string, any][] = Array.isArray(ops)
|
|
59
|
+
? ops.map((op: any) => [op.name, op])
|
|
60
|
+
: Object.entries(ops);
|
|
61
|
+
return entries.map(([name, op]) => generateOperation(name, op, service.name)).join('\n');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function generateOperation(operationName: string, operation: any, serviceName: string): string {
|
|
65
|
+
const stepsHeader = (operation.steps && operation.steps.length > 0)
|
|
66
|
+
? operation.steps.map((s: any) => ` * - ${typeof s === 'string' ? s : (s.action || JSON.stringify(s))}`).join('\n')
|
|
67
|
+
: ' * (no spec steps declared)';
|
|
68
|
+
return `
|
|
69
|
+
/**
|
|
70
|
+
* ${operationName}
|
|
71
|
+
* ${operation.description || ''}
|
|
72
|
+
*
|
|
73
|
+
* Spec steps:
|
|
74
|
+
${stepsHeader}
|
|
75
|
+
*/
|
|
76
|
+
public async ${operationName}(_args: any = {}): Promise<any> {
|
|
77
|
+
// TODO: translate spec steps into pg-native SQL calls. Controllers
|
|
78
|
+
// already inline conventions today; service-side parity is a
|
|
79
|
+
// follow-up. For now this stub keeps realize completing and the
|
|
80
|
+
// service surface callable.
|
|
81
|
+
throw new Error('${serviceName}.${operationName} is not implemented');
|
|
82
|
+
}
|
|
83
|
+
`;
|
|
84
|
+
}
|