@specverse/engines 4.1.9 → 4.1.10
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/libs/instance-factories/applications/templates/generic/main-generator.js +2 -2
- package/dist/libs/instance-factories/applications/templates/react/api-client-generator.js +8 -5
- package/dist/libs/instance-factories/controllers/templates/fastify/routes-generator.js +6 -44
- package/dist/libs/instance-factories/controllers/templates/fastify/server-generator.js +2 -52
- package/dist/libs/instance-factories/orms/templates/prisma/schema-generator.js +13 -2
- package/dist/libs/instance-factories/services/templates/prisma/controller-generator.js +3 -18
- package/dist/libs/instance-factories/views/templates/react/forms-generator.js +4 -2
- package/dist/libs/instance-factories/views/templates/react/shared-utils-generator.js +9 -1
- package/libs/instance-factories/applications/templates/generic/main-generator.ts +2 -2
- package/libs/instance-factories/applications/templates/react/api-client-generator.ts +8 -5
- package/libs/instance-factories/controllers/templates/fastify/routes-generator.ts +11 -60
- package/libs/instance-factories/controllers/templates/fastify/server-generator.ts +2 -70
- package/libs/instance-factories/orms/templates/prisma/schema-generator.ts +18 -2
- package/libs/instance-factories/services/templates/prisma/controller-generator.ts +5 -24
- package/libs/instance-factories/views/templates/react/forms-generator.ts +6 -4
- package/libs/instance-factories/views/templates/react/shared-utils-generator.ts +9 -1
- package/package.json +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { deriveBasePath } from "@specverse/types/spec-rules";
|
|
1
2
|
function generateMain(context) {
|
|
2
3
|
const { spec, manifest } = context;
|
|
3
4
|
let framework = "fastify";
|
|
@@ -52,8 +53,7 @@ function generateFastifyMain(spec) {
|
|
|
52
53
|
let prefix = ctrl.basePath;
|
|
53
54
|
if (!prefix) {
|
|
54
55
|
const modelName = ctrl.model || ctrl.name.replace("Controller", "");
|
|
55
|
-
|
|
56
|
-
prefix = `/api/${pluralModel}`;
|
|
56
|
+
prefix = deriveBasePath(modelName);
|
|
57
57
|
}
|
|
58
58
|
return ` await app.register(${ctrl.name.replace("Controller", "")}Routes, {
|
|
59
59
|
prefix: '${prefix}',
|
|
@@ -50,12 +50,10 @@ async function apiRequest<T = any>(
|
|
|
50
50
|
path: string,
|
|
51
51
|
body: any = null
|
|
52
52
|
): Promise<T> {
|
|
53
|
-
const options: RequestInit = {
|
|
54
|
-
method,
|
|
55
|
-
headers: { 'Content-Type': 'application/json' }
|
|
56
|
-
};
|
|
53
|
+
const options: RequestInit = { method };
|
|
57
54
|
|
|
58
|
-
if (body) {
|
|
55
|
+
if (body && Object.keys(body).length > 0) {
|
|
56
|
+
options.headers = { 'Content-Type': 'application/json' };
|
|
59
57
|
options.body = JSON.stringify(body);
|
|
60
58
|
}
|
|
61
59
|
|
|
@@ -65,6 +63,11 @@ async function apiRequest<T = any>(
|
|
|
65
63
|
throw new Error(\`API request failed: \${response.statusText}\`);
|
|
66
64
|
}
|
|
67
65
|
|
|
66
|
+
// Handle 204 No Content (e.g., delete) \u2014 no body to parse
|
|
67
|
+
if (response.status === 204 || response.headers.get('content-length') === '0') {
|
|
68
|
+
return {} as T;
|
|
69
|
+
}
|
|
70
|
+
|
|
68
71
|
return await response.json();
|
|
69
72
|
}
|
|
70
73
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { inferHttpMethod as sharedInferHttpMethod, inferPath as sharedInferPath, curedToEndpoints as sharedCuredToEndpoints } from "@specverse/types/spec-rules";
|
|
1
2
|
function generateFastifyRoutes(context) {
|
|
2
3
|
const { controller, model, spec, implType } = context;
|
|
3
4
|
if (!controller) {
|
|
@@ -223,57 +224,18 @@ function inferOperationFromMethodAndPath(method, path) {
|
|
|
223
224
|
return "unknown";
|
|
224
225
|
}
|
|
225
226
|
function inferHttpMethod(operation) {
|
|
226
|
-
|
|
227
|
-
console.warn("Warning: undefined operation in inferHttpMethod");
|
|
228
|
-
return "post";
|
|
229
|
-
}
|
|
230
|
-
const opLower = operation.toLowerCase();
|
|
231
|
-
if (opLower === "create" || opLower === "validate") return "post";
|
|
232
|
-
if (opLower === "retrieve" || opLower === "list") return "get";
|
|
233
|
-
if (opLower === "update" || opLower === "evolve") return "put";
|
|
234
|
-
if (opLower === "delete") return "delete";
|
|
235
|
-
return "post";
|
|
227
|
+
return sharedInferHttpMethod(operation);
|
|
236
228
|
}
|
|
237
229
|
function inferPath(operation, endpoint) {
|
|
238
|
-
|
|
239
|
-
if (opLower === "create") return "/";
|
|
240
|
-
if (opLower === "list") return "/";
|
|
241
|
-
if (opLower === "retrieve") return "/:id";
|
|
242
|
-
if (opLower === "update") return "/:id";
|
|
243
|
-
if (opLower === "evolve") return "/:id/evolve";
|
|
244
|
-
if (opLower === "delete") return "/:id";
|
|
245
|
-
if (opLower === "validate") return "/validate";
|
|
246
|
-
if (endpoint.path && endpoint.serviceOperation?.type === "custom") {
|
|
230
|
+
if (endpoint?.path && endpoint.serviceOperation?.type === "custom") {
|
|
247
231
|
const pathParts = endpoint.path.split("/").filter((p) => p);
|
|
248
232
|
const lastPart = pathParts[pathParts.length - 1];
|
|
249
233
|
return `/${lastPart}`;
|
|
250
234
|
}
|
|
251
|
-
return
|
|
235
|
+
return sharedInferPath(operation);
|
|
252
236
|
}
|
|
253
|
-
function curedToEndpoints(cured,
|
|
254
|
-
|
|
255
|
-
if (cured.create) {
|
|
256
|
-
endpoints.push({ operation: "create", method: "POST" });
|
|
257
|
-
}
|
|
258
|
-
if (cured.retrieve) {
|
|
259
|
-
endpoints.push({ operation: "retrieve", method: "GET" });
|
|
260
|
-
}
|
|
261
|
-
if (cured.retrieve_many) {
|
|
262
|
-
endpoints.push({ operation: "list", method: "GET" });
|
|
263
|
-
}
|
|
264
|
-
if (cured.update) {
|
|
265
|
-
endpoints.push({ operation: "update", method: "PUT" });
|
|
266
|
-
}
|
|
267
|
-
if (cured.evolve) {
|
|
268
|
-
endpoints.push({ operation: "evolve", method: "PATCH" });
|
|
269
|
-
}
|
|
270
|
-
if (cured.delete) {
|
|
271
|
-
endpoints.push({ operation: "delete", method: "DELETE" });
|
|
272
|
-
}
|
|
273
|
-
if (cured.validate) {
|
|
274
|
-
endpoints.push({ operation: "validate", method: "POST" });
|
|
275
|
-
}
|
|
276
|
-
return endpoints;
|
|
237
|
+
function curedToEndpoints(cured, _modelName) {
|
|
238
|
+
return sharedCuredToEndpoints(cured);
|
|
277
239
|
}
|
|
278
240
|
export {
|
|
279
241
|
generateFastifyRoutes as default
|
|
@@ -1,54 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
if (!spec) return spec;
|
|
3
|
-
const normalized = { ...spec };
|
|
4
|
-
if (normalized.models && typeof normalized.models === "object") {
|
|
5
|
-
for (const [name, model] of Object.entries(normalized.models)) {
|
|
6
|
-
if (Array.isArray(model.attributes)) {
|
|
7
|
-
const obj = {};
|
|
8
|
-
for (const attr of model.attributes) {
|
|
9
|
-
if (attr.name) obj[attr.name] = attr;
|
|
10
|
-
}
|
|
11
|
-
model.attributes = obj;
|
|
12
|
-
}
|
|
13
|
-
if (Array.isArray(model.relationships)) {
|
|
14
|
-
const obj = {};
|
|
15
|
-
for (const rel of model.relationships) {
|
|
16
|
-
if (rel.name) obj[rel.name] = { ...rel, targetModel: rel.target || rel.targetModel };
|
|
17
|
-
}
|
|
18
|
-
model.relationships = obj;
|
|
19
|
-
}
|
|
20
|
-
if (Array.isArray(model.lifecycles)) {
|
|
21
|
-
const obj = {};
|
|
22
|
-
for (const lc of model.lifecycles) {
|
|
23
|
-
if (lc.name) obj[lc.name] = lc;
|
|
24
|
-
}
|
|
25
|
-
model.lifecycles = obj;
|
|
26
|
-
}
|
|
27
|
-
if (model.lifecycles && typeof model.lifecycles === "object") {
|
|
28
|
-
for (const lc of Object.values(model.lifecycles)) {
|
|
29
|
-
if (lc.flow && typeof lc.flow === "string" && !lc.states) {
|
|
30
|
-
const stateNames = lc.flow.split("->").map((s) => s.trim()).filter(Boolean);
|
|
31
|
-
lc.states = stateNames.map((s) => ({ name: s }));
|
|
32
|
-
lc.transitions = [];
|
|
33
|
-
lc.initialState = stateNames[0];
|
|
34
|
-
for (let i = 0; i < stateNames.length - 1; i++) {
|
|
35
|
-
lc.transitions.push({ from: stateNames[i], to: stateNames[i + 1] });
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
if (normalized.views && typeof normalized.views === "object") {
|
|
43
|
-
for (const view of Object.values(normalized.views)) {
|
|
44
|
-
if (Array.isArray(view.model)) {
|
|
45
|
-
view.models = view.model;
|
|
46
|
-
view.model = view.model[0];
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
return normalized;
|
|
51
|
-
}
|
|
1
|
+
import { normalizeSpec, deriveBasePath } from "@specverse/types/spec-rules";
|
|
52
2
|
function generateFastifyServer(context) {
|
|
53
3
|
const { spec, models } = context;
|
|
54
4
|
const allModels = models || (spec?.models ? Object.values(spec.models) : []);
|
|
@@ -57,7 +7,7 @@ function generateFastifyServer(context) {
|
|
|
57
7
|
(name) => `import ${name}Routes from './routes/${name}Controller.js';`
|
|
58
8
|
).join("\n");
|
|
59
9
|
const routeRegistrations = modelNames.map((name) => {
|
|
60
|
-
const path =
|
|
10
|
+
const path = deriveBasePath(name);
|
|
61
11
|
return ` await fastify.register(${name}Routes, { prefix: '${path}', controllers: { ${name}Controller: new (await import('./controllers/${name}Controller.js')).${name}Controller() } });`;
|
|
62
12
|
}).join("\n");
|
|
63
13
|
return `/**
|
|
@@ -291,8 +291,19 @@ function generateRelationship(rel, model, relationMap, hasOneTargets, allModels)
|
|
|
291
291
|
relationDef += `"${relName}", `;
|
|
292
292
|
}
|
|
293
293
|
relationDef += `fields: [${fkBase}], references: [id]`;
|
|
294
|
-
|
|
295
|
-
|
|
294
|
+
let onDelete = rel.onDelete;
|
|
295
|
+
if (!onDelete && allModels) {
|
|
296
|
+
const parentModel = allModels.find((m) => m.name === rel.target);
|
|
297
|
+
if (parentModel) {
|
|
298
|
+
const parentRels = Array.isArray(parentModel.relationships) ? parentModel.relationships : Object.values(parentModel.relationships || {});
|
|
299
|
+
const parentRel = parentRels.find((pr) => (pr.type === "hasMany" || pr.type === "hasOne") && (pr.target === model.name || pr.targetModel === model.name));
|
|
300
|
+
if (parentRel?.cascade || parentRel?.cascadeDelete) {
|
|
301
|
+
onDelete = "Cascade";
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (onDelete) {
|
|
306
|
+
relationDef += `, onDelete: ${onDelete}`;
|
|
296
307
|
}
|
|
297
308
|
if (rel.onUpdate) {
|
|
298
309
|
relationDef += `, onUpdate: ${rel.onUpdate}`;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { buildTransitionMap, isAutoField } from "@specverse/types/spec-rules";
|
|
1
2
|
function generatePrismaController(context) {
|
|
2
3
|
const { controller, model, spec, models: allModels } = context;
|
|
3
4
|
if (!controller) {
|
|
@@ -74,9 +75,8 @@ function generateValidationLogic(model, dataParam = "_data", contextParam = "_co
|
|
|
74
75
|
if (!model.attributes) return "// No validation rules defined";
|
|
75
76
|
const validations = [];
|
|
76
77
|
const attrList = Array.isArray(model.attributes) ? model.attributes.map((a) => [a.name, a]) : Object.entries(model.attributes);
|
|
77
|
-
const AUTO_FIELDS = /* @__PURE__ */ new Set(["id", "createdAt", "updatedAt", "created_at", "updated_at", "createdBy", "updatedBy"]);
|
|
78
78
|
attrList.forEach(([name, attr]) => {
|
|
79
|
-
if (attr.required && !
|
|
79
|
+
if (attr.required && !isAutoField(name, attr)) {
|
|
80
80
|
validations.push(`
|
|
81
81
|
// ${name} is required
|
|
82
82
|
if (${contextParam}.operation === 'create' && !${dataParam}.${name}) {
|
|
@@ -225,22 +225,7 @@ function generateEvolveMethod(model, modelName, modelVar, controller) {
|
|
|
225
225
|
const lifecycle = lifecycles[0];
|
|
226
226
|
const lifecycleName = lifecycle?.name || "status";
|
|
227
227
|
const states = lifecycle?.states || [];
|
|
228
|
-
const validTransitions = {};
|
|
229
|
-
if (states.length > 1) {
|
|
230
|
-
for (let i = 0; i < states.length - 1; i++) {
|
|
231
|
-
validTransitions[states[i]] = [states[i + 1]];
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
if (lifecycle?.transitions) {
|
|
235
|
-
const transitions = Array.isArray(lifecycle.transitions) ? lifecycle.transitions : Object.entries(lifecycle.transitions).map(([name, t]) => ({ name, ...t }));
|
|
236
|
-
for (const t of transitions) {
|
|
237
|
-
const fromStates = Array.isArray(t.from) ? t.from : [t.from];
|
|
238
|
-
for (const from of fromStates) {
|
|
239
|
-
if (!validTransitions[from]) validTransitions[from] = [];
|
|
240
|
-
if (!validTransitions[from].includes(t.to)) validTransitions[from].push(t.to);
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
}
|
|
228
|
+
const validTransitions = lifecycle ? buildTransitionMap(lifecycle) : {};
|
|
244
229
|
return `
|
|
245
230
|
/**
|
|
246
231
|
* Evolve ${modelName} through lifecycle
|
|
@@ -135,7 +135,8 @@ function generateSchemaFields(model) {
|
|
|
135
135
|
}
|
|
136
136
|
const fields = [];
|
|
137
137
|
for (const [name, config] of Object.entries(attributes)) {
|
|
138
|
-
|
|
138
|
+
const METADATA_FIELDS = ["id", "createdAt", "updatedAt", "createdBy", "updatedBy", "deletedAt", "version"];
|
|
139
|
+
if (METADATA_FIELDS.includes(name)) continue;
|
|
139
140
|
const attr = config;
|
|
140
141
|
if (attr.auto || attr.autoGenerate) continue;
|
|
141
142
|
const autoTimestampFields = ["joinedAt", "registeredAt", "enrolledAt", "startedAt", "completedAt", "verifiedAt"];
|
|
@@ -182,7 +183,8 @@ function generateFormFields(model) {
|
|
|
182
183
|
const regularFields = [];
|
|
183
184
|
const textAreaFields = [];
|
|
184
185
|
for (const [name, config] of Object.entries(attributes)) {
|
|
185
|
-
|
|
186
|
+
const METADATA_FIELDS = ["id", "createdAt", "updatedAt", "createdBy", "updatedBy", "deletedAt", "version"];
|
|
187
|
+
if (METADATA_FIELDS.includes(name)) continue;
|
|
186
188
|
const attr = config;
|
|
187
189
|
if (attr.auto || attr.autoGenerate) continue;
|
|
188
190
|
const autoTimestampFields = ["joinedAt", "registeredAt", "enrolledAt", "startedAt", "completedAt", "verifiedAt"];
|
|
@@ -40,9 +40,17 @@ export function isMetadataField(name: string): boolean {
|
|
|
40
40
|
*/
|
|
41
41
|
export function getEntityDisplayName(entity: any): string {
|
|
42
42
|
if (!entity) return 'Unknown';
|
|
43
|
-
|
|
43
|
+
const METADATA = new Set(['id', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy', 'deletedAt', 'version']);
|
|
44
|
+
// Check common name fields first
|
|
45
|
+
for (const field of ['name', 'title', 'displayName', 'label', 'username', 'email', 'subject', 'message', 'description']) {
|
|
44
46
|
if (entity[field]) return String(entity[field]);
|
|
45
47
|
}
|
|
48
|
+
// Fall back to first non-metadata string value
|
|
49
|
+
for (const [key, val] of Object.entries(entity)) {
|
|
50
|
+
if (!METADATA.has(key) && val && typeof val === 'string' && !String(val).match(/^[0-9a-f-]{36}$/i)) {
|
|
51
|
+
return String(val);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
46
54
|
return entity.id ? String(entity.id).slice(0, 8) + '...' : 'Unknown';
|
|
47
55
|
}
|
|
48
56
|
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { TemplateContext } from '@specverse/types';
|
|
8
|
+
import { deriveBasePath, normalizeSpec } from '@specverse/types/spec-rules';
|
|
8
9
|
|
|
9
10
|
export default function generateMain(context: TemplateContext): string {
|
|
10
11
|
const { spec, manifest } = context;
|
|
@@ -82,8 +83,7 @@ function generateFastifyMain(spec: any): string {
|
|
|
82
83
|
let prefix = ctrl.basePath;
|
|
83
84
|
if (!prefix) {
|
|
84
85
|
const modelName = ctrl.model || ctrl.name.replace('Controller', '');
|
|
85
|
-
|
|
86
|
-
prefix = `/api/${pluralModel}`;
|
|
86
|
+
prefix = deriveBasePath(modelName);
|
|
87
87
|
}
|
|
88
88
|
return ` await app.register(${ctrl.name.replace('Controller', '')}Routes, {
|
|
89
89
|
prefix: '${prefix}',
|
|
@@ -83,12 +83,10 @@ async function apiRequest<T = any>(
|
|
|
83
83
|
path: string,
|
|
84
84
|
body: any = null
|
|
85
85
|
): Promise<T> {
|
|
86
|
-
const options: RequestInit = {
|
|
87
|
-
method,
|
|
88
|
-
headers: { 'Content-Type': 'application/json' }
|
|
89
|
-
};
|
|
86
|
+
const options: RequestInit = { method };
|
|
90
87
|
|
|
91
|
-
if (body) {
|
|
88
|
+
if (body && Object.keys(body).length > 0) {
|
|
89
|
+
options.headers = { 'Content-Type': 'application/json' };
|
|
92
90
|
options.body = JSON.stringify(body);
|
|
93
91
|
}
|
|
94
92
|
|
|
@@ -98,6 +96,11 @@ async function apiRequest<T = any>(
|
|
|
98
96
|
throw new Error(\`API request failed: \${response.statusText}\`);
|
|
99
97
|
}
|
|
100
98
|
|
|
99
|
+
// Handle 204 No Content (e.g., delete) — no body to parse
|
|
100
|
+
if (response.status === 204 || response.headers.get('content-length') === '0') {
|
|
101
|
+
return {} as T;
|
|
102
|
+
}
|
|
103
|
+
|
|
101
104
|
return await response.json();
|
|
102
105
|
}
|
|
103
106
|
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { TemplateContext } from '@specverse/types';
|
|
8
|
+
import { inferHttpMethod as sharedInferHttpMethod, inferPath as sharedInferPath, curedToEndpoints as sharedCuredToEndpoints } from '@specverse/types/spec-rules';
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Generate Fastify routes for a controller
|
|
@@ -309,81 +310,31 @@ function inferOperationFromMethodAndPath(method: string, path: string): string {
|
|
|
309
310
|
}
|
|
310
311
|
|
|
311
312
|
/**
|
|
312
|
-
* Infer HTTP method from operation name
|
|
313
|
+
* Infer HTTP method from operation name (delegates to shared spec-rules)
|
|
313
314
|
*/
|
|
314
315
|
function inferHttpMethod(operation: string): string {
|
|
315
|
-
|
|
316
|
-
console.warn('Warning: undefined operation in inferHttpMethod');
|
|
317
|
-
return 'post'; // default
|
|
318
|
-
}
|
|
319
|
-
const opLower = operation.toLowerCase();
|
|
320
|
-
|
|
321
|
-
if (opLower === 'create' || opLower === 'validate') return 'post';
|
|
322
|
-
if (opLower === 'retrieve' || opLower === 'list') return 'get';
|
|
323
|
-
if (opLower === 'update' || opLower === 'evolve') return 'put';
|
|
324
|
-
if (opLower === 'delete') return 'delete';
|
|
325
|
-
|
|
326
|
-
return 'post'; // default
|
|
316
|
+
return sharedInferHttpMethod(operation);
|
|
327
317
|
}
|
|
328
318
|
|
|
329
319
|
/**
|
|
330
|
-
* Infer path from operation name
|
|
331
|
-
*
|
|
320
|
+
* Infer path from operation name.
|
|
321
|
+
* Delegates to shared spec-rules for standard CURVED ops,
|
|
322
|
+
* handles custom service operation paths locally.
|
|
332
323
|
*/
|
|
333
324
|
function inferPath(operation: string, endpoint: any): string {
|
|
334
|
-
// Endpoint paths from AI view generator are placeholders - ignore them
|
|
335
|
-
// Always generate relative paths from operation for Fastify route registration
|
|
336
|
-
// (The prefix is set in main.ts registration, e.g., prefix: '/api/users')
|
|
337
|
-
|
|
338
|
-
const opLower = operation.toLowerCase();
|
|
339
|
-
|
|
340
|
-
if (opLower === 'create') return '/';
|
|
341
|
-
if (opLower === 'list') return '/';
|
|
342
|
-
if (opLower === 'retrieve') return '/:id';
|
|
343
|
-
if (opLower === 'update') return '/:id';
|
|
344
|
-
if (opLower === 'evolve') return '/:id/evolve';
|
|
345
|
-
if (opLower === 'delete') return '/:id';
|
|
346
|
-
if (opLower === 'validate') return '/validate';
|
|
347
|
-
|
|
348
325
|
// For custom actions, extract relative path from endpoint.path
|
|
349
|
-
|
|
350
|
-
if (endpoint.path && endpoint.serviceOperation?.type === 'custom') {
|
|
326
|
+
if (endpoint?.path && endpoint.serviceOperation?.type === 'custom') {
|
|
351
327
|
const pathParts = endpoint.path.split('/').filter((p: string) => p);
|
|
352
328
|
const lastPart = pathParts[pathParts.length - 1];
|
|
353
329
|
return `/${lastPart}`;
|
|
354
330
|
}
|
|
355
331
|
|
|
356
|
-
return
|
|
332
|
+
return sharedInferPath(operation);
|
|
357
333
|
}
|
|
358
334
|
|
|
359
335
|
/**
|
|
360
|
-
* Convert CURED operations to endpoints
|
|
336
|
+
* Convert CURED operations to endpoints (delegates to shared spec-rules)
|
|
361
337
|
*/
|
|
362
|
-
function curedToEndpoints(cured: any,
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
// Map CURED operations to endpoints with correct paths
|
|
366
|
-
if (cured.create) {
|
|
367
|
-
endpoints.push({ operation: 'create', method: 'POST' });
|
|
368
|
-
}
|
|
369
|
-
if (cured.retrieve) {
|
|
370
|
-
endpoints.push({ operation: 'retrieve', method: 'GET' });
|
|
371
|
-
}
|
|
372
|
-
if (cured.retrieve_many) {
|
|
373
|
-
endpoints.push({ operation: 'list', method: 'GET' });
|
|
374
|
-
}
|
|
375
|
-
if (cured.update) {
|
|
376
|
-
endpoints.push({ operation: 'update', method: 'PUT' });
|
|
377
|
-
}
|
|
378
|
-
if (cured.evolve) {
|
|
379
|
-
endpoints.push({ operation: 'evolve', method: 'PATCH' });
|
|
380
|
-
}
|
|
381
|
-
if (cured.delete) {
|
|
382
|
-
endpoints.push({ operation: 'delete', method: 'DELETE' });
|
|
383
|
-
}
|
|
384
|
-
if (cured.validate) {
|
|
385
|
-
endpoints.push({ operation: 'validate', method: 'POST' });
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
return endpoints;
|
|
338
|
+
function curedToEndpoints(cured: any, _modelName: string): any[] {
|
|
339
|
+
return sharedCuredToEndpoints(cured);
|
|
389
340
|
}
|
|
@@ -5,75 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { TemplateContext } from '@specverse/types';
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Generate Fastify server bootstrap
|
|
11
|
-
*/
|
|
12
|
-
/**
|
|
13
|
-
* Normalize spec data for frontend consumption.
|
|
14
|
-
* Converts array-format attributes/relationships/lifecycles to object format.
|
|
15
|
-
* Ensures model references are single strings, not arrays.
|
|
16
|
-
*/
|
|
17
|
-
function normalizeSpec(spec: any): any {
|
|
18
|
-
if (!spec) return spec;
|
|
19
|
-
const normalized = { ...spec };
|
|
20
|
-
|
|
21
|
-
// Normalize models
|
|
22
|
-
if (normalized.models && typeof normalized.models === 'object') {
|
|
23
|
-
for (const [name, model] of Object.entries(normalized.models) as [string, any][]) {
|
|
24
|
-
// Attributes: [{name:'id', type:'UUID'}] → {id: {type:'UUID'}}
|
|
25
|
-
if (Array.isArray(model.attributes)) {
|
|
26
|
-
const obj: Record<string, any> = {};
|
|
27
|
-
for (const attr of model.attributes) {
|
|
28
|
-
if (attr.name) obj[attr.name] = attr;
|
|
29
|
-
}
|
|
30
|
-
model.attributes = obj;
|
|
31
|
-
}
|
|
32
|
-
// Relationships: [{name:'author', type:'belongsTo', target:'Author'}] → {author: {..., targetModel:'Author'}}
|
|
33
|
-
if (Array.isArray(model.relationships)) {
|
|
34
|
-
const obj: Record<string, any> = {};
|
|
35
|
-
for (const rel of model.relationships) {
|
|
36
|
-
if (rel.name) obj[rel.name] = { ...rel, targetModel: rel.target || rel.targetModel };
|
|
37
|
-
}
|
|
38
|
-
model.relationships = obj;
|
|
39
|
-
}
|
|
40
|
-
// Lifecycles: [{name:'status', states:[...]}] → {status: {states:[...]}}
|
|
41
|
-
if (Array.isArray(model.lifecycles)) {
|
|
42
|
-
const obj: Record<string, any> = {};
|
|
43
|
-
for (const lc of model.lifecycles) {
|
|
44
|
-
if (lc.name) obj[lc.name] = lc;
|
|
45
|
-
}
|
|
46
|
-
model.lifecycles = obj;
|
|
47
|
-
}
|
|
48
|
-
// Parse flow strings in lifecycles: "draft -> open -> closed" → states + transitions
|
|
49
|
-
if (model.lifecycles && typeof model.lifecycles === 'object') {
|
|
50
|
-
for (const lc of Object.values(model.lifecycles) as any[]) {
|
|
51
|
-
if (lc.flow && typeof lc.flow === 'string' && !lc.states) {
|
|
52
|
-
const stateNames = lc.flow.split('->').map((s: string) => s.trim()).filter(Boolean);
|
|
53
|
-
lc.states = stateNames.map((s: string) => ({ name: s }));
|
|
54
|
-
lc.transitions = [];
|
|
55
|
-
lc.initialState = stateNames[0];
|
|
56
|
-
for (let i = 0; i < stateNames.length - 1; i++) {
|
|
57
|
-
lc.transitions.push({ from: stateNames[i], to: stateNames[i + 1] });
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Normalize views — ensure model is a single string
|
|
66
|
-
if (normalized.views && typeof normalized.views === 'object') {
|
|
67
|
-
for (const view of Object.values(normalized.views) as any[]) {
|
|
68
|
-
if (Array.isArray(view.model)) {
|
|
69
|
-
view.models = view.model; // preserve full list
|
|
70
|
-
view.model = view.model[0]; // primary model is first
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
return normalized;
|
|
76
|
-
}
|
|
8
|
+
import { normalizeSpec, deriveBasePath } from '@specverse/types/spec-rules';
|
|
77
9
|
|
|
78
10
|
export default function generateFastifyServer(context: TemplateContext): string {
|
|
79
11
|
const { spec, models } = context;
|
|
@@ -88,7 +20,7 @@ export default function generateFastifyServer(context: TemplateContext): string
|
|
|
88
20
|
).join('\n');
|
|
89
21
|
|
|
90
22
|
const routeRegistrations = modelNames.map((name: string) => {
|
|
91
|
-
const path =
|
|
23
|
+
const path = deriveBasePath(name);
|
|
92
24
|
return ` await fastify.register(${name}Routes, { prefix: '${path}', controllers: { ${name}Controller: new (await import('./controllers/${name}Controller.js')).${name}Controller() } });`;
|
|
93
25
|
}).join('\n');
|
|
94
26
|
|
|
@@ -479,8 +479,24 @@ function generateRelationship(rel: any, model: any, relationMap: Map<string, str
|
|
|
479
479
|
}
|
|
480
480
|
relationDef += `fields: [${fkBase}], references: [id]`;
|
|
481
481
|
|
|
482
|
-
if
|
|
483
|
-
|
|
482
|
+
// Check if parent model declares cascade on its hasMany/hasOne side
|
|
483
|
+
let onDelete = rel.onDelete;
|
|
484
|
+
if (!onDelete && allModels) {
|
|
485
|
+
const parentModel = allModels.find((m: any) => m.name === rel.target);
|
|
486
|
+
if (parentModel) {
|
|
487
|
+
const parentRels = Array.isArray(parentModel.relationships)
|
|
488
|
+
? parentModel.relationships
|
|
489
|
+
: Object.values(parentModel.relationships || {});
|
|
490
|
+
const parentRel = parentRels.find((pr: any) =>
|
|
491
|
+
(pr.type === 'hasMany' || pr.type === 'hasOne') &&
|
|
492
|
+
(pr.target === model.name || pr.targetModel === model.name));
|
|
493
|
+
if (parentRel?.cascade || parentRel?.cascadeDelete) {
|
|
494
|
+
onDelete = 'Cascade';
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
if (onDelete) {
|
|
499
|
+
relationDef += `, onDelete: ${onDelete}`;
|
|
484
500
|
}
|
|
485
501
|
if (rel.onUpdate) {
|
|
486
502
|
relationDef += `, onUpdate: ${rel.onUpdate}`;
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { TemplateContext } from '@specverse/types';
|
|
9
|
+
import { buildTransitionMap, isAutoField } from '@specverse/types/spec-rules';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Generate Prisma controller for a model
|
|
@@ -106,12 +107,9 @@ function generateValidationLogic(model: any, dataParam: string = '_data', contex
|
|
|
106
107
|
const attrList = Array.isArray(model.attributes)
|
|
107
108
|
? model.attributes.map((a: any) => [a.name, a])
|
|
108
109
|
: Object.entries(model.attributes);
|
|
109
|
-
// Fields that are auto-generated and should not be required on create
|
|
110
|
-
const AUTO_FIELDS = new Set(['id', 'createdAt', 'updatedAt', 'created_at', 'updated_at', 'createdBy', 'updatedBy']);
|
|
111
|
-
|
|
112
110
|
attrList.forEach(([name, attr]: [string, any]) => {
|
|
113
|
-
// Required validation — skip auto-generated fields
|
|
114
|
-
if (attr.required && !
|
|
111
|
+
// Required validation — skip auto-generated fields (uses shared spec-rules)
|
|
112
|
+
if (attr.required && !isAutoField(name, attr)) {
|
|
115
113
|
validations.push(`
|
|
116
114
|
// ${name} is required
|
|
117
115
|
if (${contextParam}.operation === 'create' && !${dataParam}.${name}) {
|
|
@@ -288,25 +286,8 @@ function generateEvolveMethod(model: any, modelName: string, modelVar: string, c
|
|
|
288
286
|
const lifecycleName = lifecycle?.name || 'status';
|
|
289
287
|
const states = lifecycle?.states || [];
|
|
290
288
|
|
|
291
|
-
// Build transition map
|
|
292
|
-
const validTransitions
|
|
293
|
-
if (states.length > 1) {
|
|
294
|
-
for (let i = 0; i < states.length - 1; i++) {
|
|
295
|
-
validTransitions[states[i]] = [states[i + 1]];
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
// Add cancel transitions if spec defines them
|
|
299
|
-
if (lifecycle?.transitions) {
|
|
300
|
-
const transitions = Array.isArray(lifecycle.transitions) ? lifecycle.transitions :
|
|
301
|
-
Object.entries(lifecycle.transitions).map(([name, t]: [string, any]) => ({ name, ...t }));
|
|
302
|
-
for (const t of transitions) {
|
|
303
|
-
const fromStates = Array.isArray(t.from) ? t.from : [t.from];
|
|
304
|
-
for (const from of fromStates) {
|
|
305
|
-
if (!validTransitions[from]) validTransitions[from] = [];
|
|
306
|
-
if (!validTransitions[from].includes(t.to)) validTransitions[from].push(t.to);
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
}
|
|
289
|
+
// Build transition map using shared spec-rules
|
|
290
|
+
const validTransitions = lifecycle ? buildTransitionMap(lifecycle) : {};
|
|
310
291
|
|
|
311
292
|
return `
|
|
312
293
|
/**
|
|
@@ -158,8 +158,9 @@ function generateSchemaFields(model: any): string {
|
|
|
158
158
|
const fields: string[] = [];
|
|
159
159
|
|
|
160
160
|
for (const [name, config] of Object.entries(attributes)) {
|
|
161
|
-
// Skip
|
|
162
|
-
|
|
161
|
+
// Skip metadata and auto-generated fields
|
|
162
|
+
const METADATA_FIELDS = ['id', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy', 'deletedAt', 'version'];
|
|
163
|
+
if (METADATA_FIELDS.includes(name)) continue;
|
|
163
164
|
|
|
164
165
|
const attr: any = config;
|
|
165
166
|
|
|
@@ -229,8 +230,9 @@ function generateFormFields(model: any): string {
|
|
|
229
230
|
const textAreaFields: string[] = [];
|
|
230
231
|
|
|
231
232
|
for (const [name, config] of Object.entries(attributes)) {
|
|
232
|
-
// Skip
|
|
233
|
-
|
|
233
|
+
// Skip metadata and auto-generated fields
|
|
234
|
+
const METADATA_FIELDS = ['id', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy', 'deletedAt', 'version'];
|
|
235
|
+
if (METADATA_FIELDS.includes(name)) continue;
|
|
234
236
|
|
|
235
237
|
const attr: any = config;
|
|
236
238
|
|
|
@@ -51,9 +51,17 @@ export function isMetadataField(name: string): boolean {
|
|
|
51
51
|
*/
|
|
52
52
|
export function getEntityDisplayName(entity: any): string {
|
|
53
53
|
if (!entity) return 'Unknown';
|
|
54
|
-
|
|
54
|
+
const METADATA = new Set(['id', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy', 'deletedAt', 'version']);
|
|
55
|
+
// Check common name fields first
|
|
56
|
+
for (const field of ['name', 'title', 'displayName', 'label', 'username', 'email', 'subject', 'message', 'description']) {
|
|
55
57
|
if (entity[field]) return String(entity[field]);
|
|
56
58
|
}
|
|
59
|
+
// Fall back to first non-metadata string value
|
|
60
|
+
for (const [key, val] of Object.entries(entity)) {
|
|
61
|
+
if (!METADATA.has(key) && val && typeof val === 'string' && !String(val).match(/^[0-9a-f-]{36}$/i)) {
|
|
62
|
+
return String(val);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
57
65
|
return entity.id ? String(entity.id).slice(0, 8) + '...' : 'Unknown';
|
|
58
66
|
}
|
|
59
67
|
|