@specverse/engines 6.65.0 → 6.75.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/providers/claude-cli.d.ts +14 -0
- package/dist/ai/providers/claude-cli.d.ts.map +1 -1
- package/dist/ai/providers/claude-cli.js +167 -17
- package/dist/ai/providers/claude-cli.js.map +1 -1
- package/dist/inference/index.d.ts +1 -1
- package/dist/inference/index.d.ts.map +1 -1
- package/dist/inference/index.js +1 -1
- package/dist/inference/index.js.map +1 -1
- package/dist/inference/quint-transpiler.d.ts +18 -0
- package/dist/inference/quint-transpiler.d.ts.map +1 -1
- package/dist/inference/quint-transpiler.js +32 -0
- package/dist/inference/quint-transpiler.js.map +1 -1
- package/dist/libs/instance-factories/controllers/templates/fastify/routes-generator.js +14 -5
- package/dist/libs/instance-factories/services/mongodb-native-services.yaml +10 -0
- package/dist/libs/instance-factories/services/postgres-native-services.yaml +10 -0
- package/dist/libs/instance-factories/services/prisma-services.yaml +10 -0
- package/dist/libs/instance-factories/services/templates/_shared/guards-generator.js +209 -0
- package/dist/libs/instance-factories/services/templates/mongodb-native/controller-generator.js +110 -23
- package/dist/libs/instance-factories/services/templates/postgres-native/controller-generator.js +104 -22
- package/dist/libs/instance-factories/services/templates/prisma/controller-generator.js +133 -23
- package/dist/libs/instance-factories/services/templates/prisma/guards-generator.js +151 -0
- package/dist/parser/convention-processor.d.ts +44 -1
- package/dist/parser/convention-processor.d.ts.map +1 -1
- package/dist/parser/convention-processor.js +175 -1
- package/dist/parser/convention-processor.js.map +1 -1
- package/dist/parser/types/ast.d.ts +1 -1
- package/dist/parser/types/ast.d.ts.map +1 -1
- package/dist/parser/unified-parser.d.ts.map +1 -1
- package/dist/parser/unified-parser.js +25 -2
- package/dist/parser/unified-parser.js.map +1 -1
- package/dist/realize/index.d.ts.map +1 -1
- package/dist/realize/index.js +17 -0
- package/dist/realize/index.js.map +1 -1
- package/libs/instance-factories/controllers/templates/fastify/__tests__/actor-wiring.test.ts +80 -0
- package/libs/instance-factories/controllers/templates/fastify/routes-generator.ts +14 -5
- package/libs/instance-factories/services/mongodb-native-services.yaml +10 -0
- package/libs/instance-factories/services/postgres-native-services.yaml +10 -0
- package/libs/instance-factories/services/prisma-services.yaml +10 -0
- package/libs/instance-factories/services/templates/_shared/guards-generator.ts +296 -0
- package/libs/instance-factories/services/templates/mongodb-native/__tests__/controller-with-constraints.test.ts +192 -0
- package/libs/instance-factories/services/templates/mongodb-native/controller-generator.ts +144 -23
- package/libs/instance-factories/services/templates/postgres-native/__tests__/controller-with-constraints.test.ts +192 -0
- package/libs/instance-factories/services/templates/postgres-native/controller-generator.ts +130 -22
- package/libs/instance-factories/services/templates/prisma/__tests__/controller-with-constraints.test.ts +261 -0
- package/libs/instance-factories/services/templates/prisma/controller-generator.ts +186 -22
- package/package.json +1 -1
package/dist/libs/instance-factories/services/templates/postgres-native/controller-generator.js
CHANGED
|
@@ -15,12 +15,13 @@ function generatePgNativeController(context) {
|
|
|
15
15
|
Object.assign(modelRegistry, models);
|
|
16
16
|
}
|
|
17
17
|
const customActions = generateCustomActions(controller, modelRegistry);
|
|
18
|
-
const
|
|
19
|
-
const
|
|
18
|
+
const hasConstraints = Array.isArray(model.constraints) && model.constraints.length > 0;
|
|
19
|
+
const validate = generateValidateMethod(model, modelName, hasConstraints);
|
|
20
|
+
const create = curedOps.create ? generateCreateMethod(model, modelName, modelVar, hasConstraints) : "";
|
|
20
21
|
const retrieve = curedOps.retrieve ? generateRetrieveMethod(modelName, modelVar) : "";
|
|
21
|
-
const update = curedOps.update ? generateUpdateMethod(modelName, modelVar) : "";
|
|
22
|
-
const evolve = curedOps.evolve ? generateEvolveMethod(model, modelName, modelVar) : "";
|
|
23
|
-
const del = curedOps.delete ? generateDeleteMethod(modelName, modelVar) : "";
|
|
22
|
+
const update = curedOps.update ? generateUpdateMethod(modelName, modelVar, hasConstraints) : "";
|
|
23
|
+
const evolve = curedOps.evolve ? generateEvolveMethod(model, modelName, modelVar, hasConstraints) : "";
|
|
24
|
+
const del = curedOps.delete ? generateDeleteMethod(modelName, modelVar, hasConstraints) : "";
|
|
24
25
|
const hasEventPublishing = curedOps.create || curedOps.update || curedOps.evolve || curedOps.delete;
|
|
25
26
|
const helperImports = [
|
|
26
27
|
"insertOne",
|
|
@@ -40,6 +41,7 @@ function generatePgNativeController(context) {
|
|
|
40
41
|
import { ${helperImports} } from '../db/pgClient.js';
|
|
41
42
|
${hasEventPublishing || customActions.needsAiBehaviors ? `import { eventBus } from '../events/eventBus.js';` : ""}
|
|
42
43
|
${customActions.needsAiBehaviors ? `import * as aiBehaviors from '../behaviors/${controllerName}.ai.js';` : ""}
|
|
44
|
+
${hasConstraints ? `import { runGuards as runConstraintGuards } from './${modelName}.guards.js';` : ""}
|
|
43
45
|
|
|
44
46
|
const TABLE_NAME = '${table}';
|
|
45
47
|
|
|
@@ -64,17 +66,32 @@ function tableName(model) {
|
|
|
64
66
|
if (model?.storage?.table) return String(model.storage.table);
|
|
65
67
|
return model.name.toLowerCase() + "s";
|
|
66
68
|
}
|
|
67
|
-
function generateValidateMethod(model, modelName) {
|
|
69
|
+
function generateValidateMethod(model, modelName, hasConstraints) {
|
|
70
|
+
const opTypeUnion = hasConstraints ? `'create' | 'update' | 'evolve' | 'delete' | \`evolve.\${string}\`` : `'create' | 'update' | 'evolve'`;
|
|
71
|
+
const constraintsCheck = hasConstraints ? `
|
|
72
|
+
// Phase 2 \u2014 run model.constraints[] guards matching this operation.
|
|
73
|
+
// Slice 15b \u2014 ctx carries a per-model query helper for subquery sugars.
|
|
74
|
+
const __guardCtx = {
|
|
75
|
+
query: (modelName: string) => ({
|
|
76
|
+
exists: async (predicate: (e: any) => boolean) => {
|
|
77
|
+
const all = await findAll(modelName);
|
|
78
|
+
return all.some(predicate);
|
|
79
|
+
},
|
|
80
|
+
}),
|
|
81
|
+
};
|
|
82
|
+
const constraintViolations = await runConstraintGuards(_data, _context.operation, _actor, __guardCtx);
|
|
83
|
+
for (const v of constraintViolations) errors.push(v.message);` : "";
|
|
68
84
|
return `
|
|
69
85
|
/**
|
|
70
|
-
* Validate ${modelName} data \u2014 runs before create / update / evolve.
|
|
86
|
+
* Validate ${modelName} data \u2014 runs before create / update / evolve / delete.
|
|
71
87
|
*/
|
|
72
|
-
public validate(
|
|
88
|
+
public async validate(
|
|
73
89
|
_data: any,
|
|
74
|
-
_context: { operation:
|
|
75
|
-
|
|
90
|
+
_context: { operation: ${opTypeUnion} },
|
|
91
|
+
_actor: any = null
|
|
92
|
+
): Promise<{ valid: boolean; errors: string[] }> {
|
|
76
93
|
const errors: string[] = [];
|
|
77
|
-
${generateValidationLogic(model)}
|
|
94
|
+
${generateValidationLogic(model)}${constraintsCheck}
|
|
78
95
|
return { valid: errors.length === 0, errors };
|
|
79
96
|
}
|
|
80
97
|
`;
|
|
@@ -101,13 +118,23 @@ function generateValidationLogic(model) {
|
|
|
101
118
|
});
|
|
102
119
|
return out.join("\n") || " // No validation rules defined";
|
|
103
120
|
}
|
|
104
|
-
function generateCreateMethod(modelName, modelVar) {
|
|
121
|
+
function generateCreateMethod(model, modelName, modelVar, hasConstraints = false) {
|
|
122
|
+
const belongsToLoad = hasConstraints ? generatePgBelongsToLoad(model) : "";
|
|
123
|
+
const validateSelf = belongsToLoad ? "__mergedSelf" : "data";
|
|
105
124
|
return `
|
|
106
125
|
/**
|
|
107
126
|
* Create a new ${modelName}.
|
|
108
127
|
*/
|
|
109
|
-
public async create(data: any): Promise<any> {
|
|
110
|
-
|
|
128
|
+
public async create(data: any, _actor: any = null): Promise<any> {
|
|
129
|
+
${belongsToLoad ? `
|
|
130
|
+
// Phase 2 Slice 15 \u2014 Create-time relation loading. Mirrors prisma's
|
|
131
|
+
// pattern: for each belongsTo FK in input, load the related row and
|
|
132
|
+
// merge into self for constraint guards.
|
|
133
|
+
const __loadedRels: Record<string, any> = {};
|
|
134
|
+
${belongsToLoad}
|
|
135
|
+
const __mergedSelf = { ...data, ...__loadedRels };
|
|
136
|
+
` : ""}
|
|
137
|
+
const validation = await this.validate(${validateSelf}, { operation: 'create' }, _actor);
|
|
111
138
|
if (!validation.valid) throw new Error(\`Validation failed: \${validation.errors.join(', ')}\`);
|
|
112
139
|
|
|
113
140
|
const ${modelVar} = await insertOne(TABLE_NAME, { ...data });
|
|
@@ -117,6 +144,19 @@ function generateCreateMethod(modelName, modelVar) {
|
|
|
117
144
|
}
|
|
118
145
|
`;
|
|
119
146
|
}
|
|
147
|
+
function generatePgBelongsToLoad(model) {
|
|
148
|
+
const rels = Array.isArray(model.relationships) ? model.relationships : Object.values(model.relationships || {});
|
|
149
|
+
const belongsToRels = rels.filter((r) => r.type === "belongsTo");
|
|
150
|
+
if (belongsToRels.length === 0) return "";
|
|
151
|
+
return belongsToRels.map((rel) => {
|
|
152
|
+
const relName = rel.name;
|
|
153
|
+
const targetName = rel.target;
|
|
154
|
+
const fkField = `${relName}Id`;
|
|
155
|
+
return `if (data.${fkField}) {
|
|
156
|
+
__loadedRels.${relName} = await findOneByField('${targetName}', 'id', data.${fkField});
|
|
157
|
+
}`;
|
|
158
|
+
}).join("\n ");
|
|
159
|
+
}
|
|
120
160
|
function generateRetrieveMethod(modelName, _modelVar) {
|
|
121
161
|
return `
|
|
122
162
|
/**
|
|
@@ -140,13 +180,22 @@ function generateRetrieveMethod(modelName, _modelVar) {
|
|
|
140
180
|
}
|
|
141
181
|
`;
|
|
142
182
|
}
|
|
143
|
-
function generateUpdateMethod(modelName, modelVar) {
|
|
183
|
+
function generateUpdateMethod(modelName, modelVar, hasConstraints = false) {
|
|
144
184
|
return `
|
|
145
185
|
/**
|
|
146
186
|
* Update ${modelName}.
|
|
147
187
|
*/
|
|
148
|
-
public async update(id: string, data: any): Promise<any> {
|
|
149
|
-
|
|
188
|
+
public async update(id: string, data: any, _actor: any = null): Promise<any> {
|
|
189
|
+
${hasConstraints ? `
|
|
190
|
+
// Phase 2 \u2014 Update self-from-DB. Load + merge before validate so
|
|
191
|
+
// update-time constraints see the full entity, not just partial input.
|
|
192
|
+
const __existing = await findOneByField(TABLE_NAME, 'id', id);
|
|
193
|
+
if (!__existing) throw new Error('${modelName} not found');
|
|
194
|
+
const __merged = { ...__existing, ...data };
|
|
195
|
+
const validation = await this.validate(__merged, { operation: 'update' }, _actor);
|
|
196
|
+
` : `
|
|
197
|
+
const validation = await this.validate(data, { operation: 'update' }, _actor);
|
|
198
|
+
`}
|
|
150
199
|
if (!validation.valid) throw new Error(\`Validation failed: \${validation.errors.join(', ')}\`);
|
|
151
200
|
|
|
152
201
|
// Strip nested objects + id \u2014 only scalar fields are written.
|
|
@@ -167,7 +216,7 @@ function generateUpdateMethod(modelName, modelVar) {
|
|
|
167
216
|
}
|
|
168
217
|
`;
|
|
169
218
|
}
|
|
170
|
-
function generateEvolveMethod(model, modelName, modelVar) {
|
|
219
|
+
function generateEvolveMethod(model, modelName, modelVar, hasConstraints = false) {
|
|
171
220
|
const lifecycles = Array.isArray(model.lifecycles) ? model.lifecycles : model.lifecycles ? Object.entries(model.lifecycles).map(([name, lc]) => ({ name, ...lc })) : [];
|
|
172
221
|
const lifecycle = lifecycles[0];
|
|
173
222
|
const lifecycleName = lifecycle?.name || "status";
|
|
@@ -176,12 +225,23 @@ function generateEvolveMethod(model, modelName, modelVar) {
|
|
|
176
225
|
...Object.keys(validTransitions),
|
|
177
226
|
...Object.values(validTransitions).flat()
|
|
178
227
|
]));
|
|
228
|
+
const evolveOpsMap = {};
|
|
229
|
+
if (lifecycle?.transitions) {
|
|
230
|
+
for (const [actionName, t] of Object.entries(lifecycle.transitions)) {
|
|
231
|
+
const from = t.from;
|
|
232
|
+
const to = t.to;
|
|
233
|
+
if (typeof from === "string" && typeof to === "string") {
|
|
234
|
+
evolveOpsMap[from] = evolveOpsMap[from] ?? {};
|
|
235
|
+
evolveOpsMap[from][to] = actionName;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
179
239
|
return `
|
|
180
240
|
/**
|
|
181
241
|
* Evolve ${modelName} through lifecycle "${lifecycleName}"
|
|
182
242
|
* States: ${states.join(" \u2192 ") || "(none declared)"}
|
|
183
243
|
*/
|
|
184
|
-
public async evolve(id: string, data: any): Promise<any> {
|
|
244
|
+
public async evolve(id: string, data: any, _actor: any = null): Promise<any> {
|
|
185
245
|
const current = await findOneByField(TABLE_NAME, 'id', id);
|
|
186
246
|
if (!current) throw new Error('${modelName} not found');
|
|
187
247
|
|
|
@@ -197,7 +257,20 @@ function generateEvolveMethod(model, modelName, modelVar) {
|
|
|
197
257
|
throw new Error(\`Invalid transition: \${currentState} \u2192 \${targetState}. Allowed: \${allowed.join(', ') || 'none'}\`);
|
|
198
258
|
}
|
|
199
259
|
` : ""}
|
|
200
|
-
|
|
260
|
+
${hasConstraints ? `
|
|
261
|
+
// Phase 2 \u2014 resolve transition action name + call validate with evolve.<action>.
|
|
262
|
+
const EVOLVE_OPS: Record<string, Record<string, string>> = ${JSON.stringify(evolveOpsMap)};
|
|
263
|
+
const currentStateForOp = (current as any)[targetLifecycle];
|
|
264
|
+
const actionName = EVOLVE_OPS[currentStateForOp]?.[targetState] ?? targetState;
|
|
265
|
+
const evolveValidation = await this.validate(
|
|
266
|
+
{ ...data, ...current, [targetLifecycle]: targetState },
|
|
267
|
+
{ operation: \`evolve.\${actionName}\` as any },
|
|
268
|
+
_actor
|
|
269
|
+
);
|
|
270
|
+
if (!evolveValidation.valid) {
|
|
271
|
+
throw new Error(\`Validation failed: \${evolveValidation.errors.join(', ')}\`);
|
|
272
|
+
}
|
|
273
|
+
` : ""}
|
|
201
274
|
await updateOneById(TABLE_NAME, id, { [targetLifecycle]: targetState });
|
|
202
275
|
const ${modelVar} = await findOneByField(TABLE_NAME, 'id', id);
|
|
203
276
|
if (!${modelVar}) throw new Error('${modelName} not found after evolve');
|
|
@@ -207,13 +280,22 @@ function generateEvolveMethod(model, modelName, modelVar) {
|
|
|
207
280
|
}
|
|
208
281
|
`;
|
|
209
282
|
}
|
|
210
|
-
function generateDeleteMethod(modelName, modelVar) {
|
|
283
|
+
function generateDeleteMethod(modelName, modelVar, hasConstraints = false) {
|
|
211
284
|
return `
|
|
212
285
|
/**
|
|
213
286
|
* Delete ${modelName}.
|
|
214
287
|
*/
|
|
215
|
-
public async delete(id: string): Promise<void> {
|
|
288
|
+
public async delete(id: string, _actor: any = null): Promise<void> {
|
|
216
289
|
const ${modelVar} = await findOneByField(TABLE_NAME, 'id', id);
|
|
290
|
+
${hasConstraints ? `
|
|
291
|
+
// Phase 2 \u2014 run delete-scoped constraint guards against the loaded record.
|
|
292
|
+
if (${modelVar}) {
|
|
293
|
+
const deleteValidation = await this.validate(${modelVar}, { operation: 'delete' }, _actor);
|
|
294
|
+
if (!deleteValidation.valid) {
|
|
295
|
+
throw new Error(\`Validation failed: \${deleteValidation.errors.join(', ')}\`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
` : ""}
|
|
217
299
|
await deleteOneById(TABLE_NAME, id);
|
|
218
300
|
if (${modelVar}) {
|
|
219
301
|
await eventBus.publish('${modelName}Deleted', { ...${modelVar}, timestamp: new Date().toISOString() } as any);
|
|
@@ -19,13 +19,14 @@ function generatePrismaController(context) {
|
|
|
19
19
|
const idType = idAttr?.type || "UUID";
|
|
20
20
|
const needsIntParse = idType === "Integer" || idType === "Int" || idType === "Number";
|
|
21
21
|
const customActions = generateCustomActions(controller, modelName, modelVar);
|
|
22
|
+
const hasConstraints = Array.isArray(model.constraints) && model.constraints.length > 0;
|
|
22
23
|
const classBody = [
|
|
23
|
-
generateValidateMethod(model, modelName),
|
|
24
|
-
curedOps.create ? generateCreateMethod(model, modelName, modelVar, prismaDelegate, controller, allModels) : "",
|
|
24
|
+
generateValidateMethod(model, modelName, hasConstraints),
|
|
25
|
+
curedOps.create ? generateCreateMethod(model, modelName, modelVar, prismaDelegate, controller, allModels, hasConstraints) : "",
|
|
25
26
|
curedOps.retrieve ? generateRetrieveMethod(model, modelName, modelVar, prismaDelegate) : "",
|
|
26
|
-
curedOps.update ? generateUpdateMethod(model, modelName, modelVar, prismaDelegate, controller, allModels) : "",
|
|
27
|
-
curedOps.evolve ? generateEvolveMethod(model, modelName, modelVar, prismaDelegate, controller) : "",
|
|
28
|
-
curedOps.delete ? generateDeleteMethod(model, modelName, modelVar, prismaDelegate, controller) : "",
|
|
27
|
+
curedOps.update ? generateUpdateMethod(model, modelName, modelVar, prismaDelegate, controller, allModels, hasConstraints) : "",
|
|
28
|
+
curedOps.evolve ? generateEvolveMethod(model, modelName, modelVar, prismaDelegate, controller, hasConstraints) : "",
|
|
29
|
+
curedOps.delete ? generateDeleteMethod(model, modelName, modelVar, prismaDelegate, controller, hasConstraints) : "",
|
|
29
30
|
customActions.code
|
|
30
31
|
].filter(Boolean).join("\n ");
|
|
31
32
|
const usesPrisma = /\bprisma\b/.test(classBody);
|
|
@@ -35,7 +36,8 @@ function generatePrismaController(context) {
|
|
|
35
36
|
const imports = [
|
|
36
37
|
usesPrisma ? `import { PrismaClient } from '@prisma/client';` : "",
|
|
37
38
|
usesEventBus ? `import { eventBus } from '../events/eventBus.js';` : "",
|
|
38
|
-
usesAiBehaviors ? `import * as aiBehaviors from '../behaviors/${modelName}Controller.ai.js';` : ""
|
|
39
|
+
usesAiBehaviors ? `import * as aiBehaviors from '../behaviors/${modelName}Controller.ai.js';` : "",
|
|
40
|
+
hasConstraints ? `import { runGuards as runConstraintGuards } from './${modelName}.guards.js';` : ""
|
|
39
41
|
].filter(Boolean).join("\n");
|
|
40
42
|
const declarations = [
|
|
41
43
|
usesPrisma ? `const prisma = new PrismaClient();` : "",
|
|
@@ -62,19 +64,39 @@ export const ${modelVar}Controller = new ${controllerName}();
|
|
|
62
64
|
export default ${modelVar}Controller;
|
|
63
65
|
`;
|
|
64
66
|
}
|
|
65
|
-
function generateValidateMethod(model, modelName) {
|
|
67
|
+
function generateValidateMethod(model, modelName, hasConstraints) {
|
|
68
|
+
const opTypeUnion = hasConstraints ? `'create' | 'update' | 'evolve' | 'delete' | \`evolve.\${string}\`` : `'create' | 'update' | 'evolve'`;
|
|
69
|
+
const constraintsCheck = hasConstraints ? `
|
|
70
|
+
// Phase 2 \u2014 run model.constraints[] guards matching this operation.
|
|
71
|
+
// ctx carries a per-model query helper so subquery sugars (rewritten
|
|
72
|
+
// to async guards by guards-generator) can run their findFirst calls.
|
|
73
|
+
const __guardCtx = {
|
|
74
|
+
query: (modelName: string) => {
|
|
75
|
+
const delegate = (prisma as any)[modelName.charAt(0).toLowerCase() + modelName.slice(1)];
|
|
76
|
+
if (!delegate) return undefined;
|
|
77
|
+
return {
|
|
78
|
+
exists: async (predicate: (e: any) => boolean) => {
|
|
79
|
+
const all = await delegate.findMany();
|
|
80
|
+
return all.some(predicate);
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
const constraintViolations = await runConstraintGuards(_data, _context.operation, _actor, __guardCtx);
|
|
86
|
+
for (const v of constraintViolations) errors.push(v.message);` : "";
|
|
66
87
|
return `
|
|
67
88
|
/**
|
|
68
89
|
* Validate ${modelName} data
|
|
69
90
|
* Unified validation method for all operations
|
|
70
91
|
*/
|
|
71
|
-
public validate(
|
|
92
|
+
public async validate(
|
|
72
93
|
_data: any,
|
|
73
|
-
_context: { operation:
|
|
74
|
-
|
|
94
|
+
_context: { operation: ${opTypeUnion} },
|
|
95
|
+
_actor: any = null
|
|
96
|
+
): Promise<{ valid: boolean; errors: string[] }> {
|
|
75
97
|
const errors: string[] = [];
|
|
76
98
|
|
|
77
|
-
${generateValidationLogic(model, "_data", "_context")}
|
|
99
|
+
${generateValidationLogic(model, "_data", "_context")}${constraintsCheck}
|
|
78
100
|
|
|
79
101
|
return {
|
|
80
102
|
valid: errors.length === 0,
|
|
@@ -125,14 +147,27 @@ function generateValidationLogic(model, dataParam = "_data", contextParam = "_co
|
|
|
125
147
|
});
|
|
126
148
|
return validations.join("\n") || "// No validation rules defined";
|
|
127
149
|
}
|
|
128
|
-
function generateCreateMethod(model, modelName, modelVar, prismaDelegate, controller, allModels) {
|
|
150
|
+
function generateCreateMethod(model, modelName, modelVar, prismaDelegate, controller, allModels, hasConstraints = false) {
|
|
151
|
+
const belongsToLoad = hasConstraints ? generateBelongsToLoad(model, allModels) : "";
|
|
152
|
+
const validateSelf = belongsToLoad ? "__mergedSelf" : "data";
|
|
129
153
|
return `
|
|
130
154
|
/**
|
|
131
155
|
* Create a new ${modelName}
|
|
132
156
|
*/
|
|
133
|
-
public async create(data: any): Promise<any> {
|
|
157
|
+
public async create(data: any, _actor: any = null): Promise<any> {
|
|
158
|
+
${belongsToLoad ? `
|
|
159
|
+
// Phase 2 Slice 15 \u2014 Create-time relation loading. For each belongsTo
|
|
160
|
+
// FK present in input, load the related entity and merge into self
|
|
161
|
+
// so constraints traversing self.<rel>.<field> see the full related
|
|
162
|
+
// record (not just the FK id). Mirrors Slice 8's Update pattern but
|
|
163
|
+
// for the create path. Only fires when the model has declared
|
|
164
|
+
// constraints; one extra round-trip per FK in input.
|
|
165
|
+
const __loadedRels: Record<string, any> = {};
|
|
166
|
+
${belongsToLoad}
|
|
167
|
+
const __mergedSelf = { ...data, ...__loadedRels };
|
|
168
|
+
` : ""}
|
|
134
169
|
// Validate input
|
|
135
|
-
const validationResult = this.validate(
|
|
170
|
+
const validationResult = await this.validate(${validateSelf}, { operation: 'create' }, _actor);
|
|
136
171
|
if (!validationResult.valid) {
|
|
137
172
|
throw new Error(\`Validation failed: \${validationResult.errors.join(', ')}\`);
|
|
138
173
|
}
|
|
@@ -153,6 +188,33 @@ function generateCreateMethod(model, modelName, modelVar, prismaDelegate, contro
|
|
|
153
188
|
}
|
|
154
189
|
`;
|
|
155
190
|
}
|
|
191
|
+
function generateBelongsToLoad(model, allModels) {
|
|
192
|
+
const rels = Array.isArray(model.relationships) ? model.relationships : Object.values(model.relationships || {});
|
|
193
|
+
const belongsToRels = rels.filter((r) => r.type === "belongsTo");
|
|
194
|
+
if (belongsToRels.length === 0) return "";
|
|
195
|
+
const RESERVED_WORDS = /* @__PURE__ */ new Set(["import", "export", "default", "class", "function", "return", "delete", "new", "this", "switch", "case", "break", "continue", "for", "while", "do", "if", "else", "try", "catch", "finally", "throw", "typeof", "instanceof", "in", "of", "let", "const", "var", "void", "with", "yield", "async", "await", "enum", "implements", "interface", "package", "private", "protected", "public", "static", "super", "extends"]);
|
|
196
|
+
return belongsToRels.map((rel) => {
|
|
197
|
+
const relName = rel.name;
|
|
198
|
+
const targetName = rel.target;
|
|
199
|
+
const fkField = `${relName}Id`;
|
|
200
|
+
const targetVar = targetName.charAt(0).toLowerCase() + targetName.slice(1);
|
|
201
|
+
const targetDelegate = RESERVED_WORDS.has(targetVar) ? `prisma['${targetVar}']` : `prisma.${targetVar}`;
|
|
202
|
+
let idExpr = `data.${fkField}`;
|
|
203
|
+
if (allModels) {
|
|
204
|
+
const targetModel = allModels.find((m) => m.name === targetName);
|
|
205
|
+
if (targetModel) {
|
|
206
|
+
const idAttr = (Array.isArray(targetModel.attributes) ? targetModel.attributes : Object.values(targetModel.attributes || {})).find((a) => a.name === "id");
|
|
207
|
+
const idType = idAttr?.type || "String";
|
|
208
|
+
if (idType === "Integer" || idType === "Int" || idType === "Number") {
|
|
209
|
+
idExpr = `parseInt(data.${fkField}, 10)`;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return `if (data.${fkField}) {
|
|
214
|
+
__loadedRels.${relName} = await ${targetDelegate}.findUnique({ where: { id: ${idExpr} } });
|
|
215
|
+
}`;
|
|
216
|
+
}).join("\n ");
|
|
217
|
+
}
|
|
156
218
|
function generateRetrieveMethod(model, modelName, modelVar, prismaDelegate) {
|
|
157
219
|
return `
|
|
158
220
|
/**
|
|
@@ -176,14 +238,26 @@ function generateRetrieveMethod(model, modelName, modelVar, prismaDelegate) {
|
|
|
176
238
|
}
|
|
177
239
|
`;
|
|
178
240
|
}
|
|
179
|
-
function generateUpdateMethod(model, modelName, modelVar, prismaDelegate, controller, allModels) {
|
|
241
|
+
function generateUpdateMethod(model, modelName, modelVar, prismaDelegate, controller, allModels, hasConstraints = false) {
|
|
180
242
|
return `
|
|
181
243
|
/**
|
|
182
244
|
* Update ${modelName}
|
|
183
245
|
*/
|
|
184
|
-
public async update(id: string, data: any): Promise<any> {
|
|
246
|
+
public async update(id: string, data: any, _actor: any = null): Promise<any> {
|
|
247
|
+
${hasConstraints ? `
|
|
248
|
+
// Phase 2 \u2014 Update self-from-DB: load the entity first and validate
|
|
249
|
+
// against the MERGED \`loaded + input\` shape so update-time constraints
|
|
250
|
+
// like \`self.poll.votingStatus == "open"\` see the full record even
|
|
251
|
+
// when the caller only sent a partial payload. Costs one extra DB
|
|
252
|
+
// round-trip; only fires for models with declared constraints.
|
|
253
|
+
const __existing = await ${prismaDelegate}.findUnique({ where: { id: parseId(id) }${generateIncludeRelationships(model)} });
|
|
254
|
+
if (!__existing) throw new Error('${modelName} not found');
|
|
255
|
+
const __merged = { ...__existing, ...data };
|
|
256
|
+
const validationResult = await this.validate(__merged, { operation: 'update' }, _actor);
|
|
257
|
+
` : `
|
|
185
258
|
// Validate input
|
|
186
|
-
const validationResult = this.validate(data, { operation: 'update' });
|
|
259
|
+
const validationResult = await this.validate(data, { operation: 'update' }, _actor);
|
|
260
|
+
`}
|
|
187
261
|
if (!validationResult.valid) {
|
|
188
262
|
throw new Error(\`Validation failed: \${validationResult.errors.join(', ')}\`);
|
|
189
263
|
}
|
|
@@ -213,12 +287,23 @@ function generateUpdateMethod(model, modelName, modelVar, prismaDelegate, contro
|
|
|
213
287
|
}
|
|
214
288
|
`;
|
|
215
289
|
}
|
|
216
|
-
function generateEvolveMethod(model, modelName, modelVar, prismaDelegate, controller) {
|
|
290
|
+
function generateEvolveMethod(model, modelName, modelVar, prismaDelegate, controller, hasConstraints = false) {
|
|
217
291
|
const lifecycles = Array.isArray(model.lifecycles) ? model.lifecycles : model.lifecycles ? Object.entries(model.lifecycles).map(([name, lc]) => ({ name, ...lc })) : [];
|
|
218
292
|
const lifecycle = lifecycles[0];
|
|
219
293
|
const lifecycleName = lifecycle?.name || "status";
|
|
220
294
|
const states = lifecycle?.states || [];
|
|
221
295
|
const validTransitions = lifecycle ? buildTransitionMap(lifecycle) : {};
|
|
296
|
+
const evolveOpsMap = {};
|
|
297
|
+
if (lifecycle?.transitions) {
|
|
298
|
+
for (const [actionName, t] of Object.entries(lifecycle.transitions)) {
|
|
299
|
+
const from = t.from;
|
|
300
|
+
const to = t.to;
|
|
301
|
+
if (typeof from === "string" && typeof to === "string") {
|
|
302
|
+
evolveOpsMap[from] = evolveOpsMap[from] ?? {};
|
|
303
|
+
evolveOpsMap[from][to] = actionName;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
222
307
|
return `
|
|
223
308
|
/**
|
|
224
309
|
* Evolve ${modelName} through lifecycle "${lifecycleName}"
|
|
@@ -229,7 +314,7 @@ function generateEvolveMethod(model, modelName, modelVar, prismaDelegate, contro
|
|
|
229
314
|
* runtime's useTransitionStateMutation always sends the former; the
|
|
230
315
|
* realized smoke-parity script can send either.
|
|
231
316
|
*/
|
|
232
|
-
public async evolve(id: string, data: any): Promise<any> {
|
|
317
|
+
public async evolve(id: string, data: any, _actor: any = null): Promise<any> {
|
|
233
318
|
// Get current record to check lifecycle state
|
|
234
319
|
const current = await ${prismaDelegate}.findUnique({ where: { id: parseId(id) } });
|
|
235
320
|
if (!current) {
|
|
@@ -254,7 +339,22 @@ function generateEvolveMethod(model, modelName, modelVar, prismaDelegate, contro
|
|
|
254
339
|
throw new Error(\`Invalid transition: \${currentState} \u2192 \${targetState}. Allowed: \${allowed.join(', ') || 'none'}\`);
|
|
255
340
|
}
|
|
256
341
|
` : ""}
|
|
257
|
-
|
|
342
|
+
${hasConstraints ? `
|
|
343
|
+
// Phase 2 \u2014 resolve the transition's action name so constraints scoped
|
|
344
|
+
// to \`on: 'evolve.<action>'\` match correctly. EVOLVE_OPS is baked at
|
|
345
|
+
// codegen from the lifecycle definition.
|
|
346
|
+
const EVOLVE_OPS: Record<string, Record<string, string>> = ${JSON.stringify(evolveOpsMap)};
|
|
347
|
+
const currentStateForOp = (current as any)[targetLifecycle];
|
|
348
|
+
const actionName = EVOLVE_OPS[currentStateForOp]?.[targetState] ?? targetState;
|
|
349
|
+
const evolveValidation = await this.validate(
|
|
350
|
+
{ ...data, ...current, [targetLifecycle]: targetState },
|
|
351
|
+
{ operation: \`evolve.\${actionName}\` as any },
|
|
352
|
+
_actor
|
|
353
|
+
);
|
|
354
|
+
if (!evolveValidation.valid) {
|
|
355
|
+
throw new Error(\`Validation failed: \${evolveValidation.errors.join(', ')}\`);
|
|
356
|
+
}
|
|
357
|
+
` : ""}
|
|
258
358
|
// Build the Prisma update payload \u2014 only the lifecycle column
|
|
259
359
|
// changes. Strips toState/lifecycleName/state so Prisma doesn't
|
|
260
360
|
// reject unknown fields.
|
|
@@ -273,15 +373,25 @@ function generateEvolveMethod(model, modelName, modelVar, prismaDelegate, contro
|
|
|
273
373
|
}
|
|
274
374
|
`;
|
|
275
375
|
}
|
|
276
|
-
function generateDeleteMethod(model, modelName, modelVar, prismaDelegate, controller) {
|
|
376
|
+
function generateDeleteMethod(model, modelName, modelVar, prismaDelegate, controller, hasConstraints = false) {
|
|
277
377
|
return `
|
|
278
378
|
/**
|
|
279
379
|
* Delete ${modelName}
|
|
280
380
|
*/
|
|
281
|
-
public async delete(id: string): Promise<void> {
|
|
381
|
+
public async delete(id: string, _actor: any = null): Promise<void> {
|
|
282
382
|
// Get record before deletion for event
|
|
283
383
|
const ${modelVar} = await ${prismaDelegate}.findUnique({ where: { id: parseId(id) } });
|
|
284
|
-
|
|
384
|
+
${hasConstraints ? `
|
|
385
|
+
// Phase 2 \u2014 run delete-scoped constraint guards against the loaded
|
|
386
|
+
// record. \`self\` is the entity being deleted (loaded above), giving
|
|
387
|
+
// delete-time constraints access to the entity's current field values.
|
|
388
|
+
if (${modelVar}) {
|
|
389
|
+
const deleteValidation = await this.validate(${modelVar}, { operation: 'delete' }, _actor);
|
|
390
|
+
if (!deleteValidation.valid) {
|
|
391
|
+
throw new Error(\`Validation failed: \${deleteValidation.errors.join(', ')}\`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
` : ""}
|
|
285
395
|
await ${prismaDelegate}.delete({
|
|
286
396
|
where: { id: parseId(id) }
|
|
287
397
|
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { transpilePhase2Guard } from "@specverse/engines/inference";
|
|
2
|
+
function generatePrismaGuards(context) {
|
|
3
|
+
const { model } = context;
|
|
4
|
+
if (!model) {
|
|
5
|
+
throw new Error("Model is required for guards generation");
|
|
6
|
+
}
|
|
7
|
+
const constraints = model.constraints ?? [];
|
|
8
|
+
if (constraints.length === 0) {
|
|
9
|
+
return generateEmptyGuardsModule(model.name);
|
|
10
|
+
}
|
|
11
|
+
const guardFns = [];
|
|
12
|
+
const constraintsTable = [];
|
|
13
|
+
for (const c of constraints) {
|
|
14
|
+
const transpiled = transpilePhase2Guard(
|
|
15
|
+
c.requires.name,
|
|
16
|
+
c.requires.params ?? `self: any, actor: any`,
|
|
17
|
+
c.requires.body
|
|
18
|
+
);
|
|
19
|
+
guardFns.push(transpiled.typescript);
|
|
20
|
+
const onArrayLiteral = `[${c.on.map((o) => JSON.stringify(o)).join(", ")}]`;
|
|
21
|
+
const sourceLiteral = JSON.stringify(
|
|
22
|
+
c.requires.source.input ?? ""
|
|
23
|
+
);
|
|
24
|
+
constraintsTable.push(
|
|
25
|
+
` {
|
|
26
|
+
on: ${onArrayLiteral},
|
|
27
|
+
guard: ${c.requires.name},
|
|
28
|
+
name: ${JSON.stringify(c.requires.name)},
|
|
29
|
+
source: ${sourceLiteral}
|
|
30
|
+
}`
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return `/**
|
|
34
|
+
* Auto-generated by SpecVerse Phase 2 (Validate-Centric Constraints).
|
|
35
|
+
*
|
|
36
|
+
* Guard functions for ${model.name}'s declared constraints.
|
|
37
|
+
* DO NOT EDIT \u2014 regenerated on every \`spv realize all\`.
|
|
38
|
+
*
|
|
39
|
+
* Slice 1 limitations:
|
|
40
|
+
* - \`self\` is the input payload (not the loaded entity). For Update/Delete/
|
|
41
|
+
* Evolve, paths through \`self\` see only the fields the caller sent.
|
|
42
|
+
* - \`actor\` defaults to null. Constraints using \`actor.*\` paths fail at
|
|
43
|
+
* runtime when called without a user context.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
export interface Violation {
|
|
47
|
+
constraint: string;
|
|
48
|
+
scope: string;
|
|
49
|
+
source: string;
|
|
50
|
+
message: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ConstraintRecord {
|
|
54
|
+
on: string[];
|
|
55
|
+
guard: (self: any, actor: any) => boolean;
|
|
56
|
+
name: string;
|
|
57
|
+
source: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
${guardFns.join("\n\n")}
|
|
61
|
+
|
|
62
|
+
export const MODEL_CONSTRAINTS: ConstraintRecord[] = [
|
|
63
|
+
${constraintsTable.join(",\n")}
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Match a runtime operation against a constraint's \`on:\` array.
|
|
68
|
+
*
|
|
69
|
+
* Op shapes:
|
|
70
|
+
* - exact string: 'create', 'update', 'delete', 'retrieve'
|
|
71
|
+
* - 'evolve.<transition>': specific lifecycle transition
|
|
72
|
+
*
|
|
73
|
+
* \`on:\` entry shapes:
|
|
74
|
+
* - '*': always matches
|
|
75
|
+
* - 'evolve.*': matches any 'evolve.X' op
|
|
76
|
+
* - else: exact-string equality
|
|
77
|
+
*/
|
|
78
|
+
export function matchesOp(constraintOn: string[], op: string): boolean {
|
|
79
|
+
for (const o of constraintOn) {
|
|
80
|
+
if (o === '*') return true;
|
|
81
|
+
if (o === op) return true;
|
|
82
|
+
if (o === 'evolve.*' && op.startsWith('evolve.')) return true;
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Run all constraints whose \`on:\` matches the requested op. Each guard
|
|
89
|
+
* is invoked with (input, actor). Returns the collected violations.
|
|
90
|
+
*
|
|
91
|
+
* A guard that returns false produces one Violation; a guard that throws
|
|
92
|
+
* (e.g. actor=null with an actor.* path) is caught and surfaced as a
|
|
93
|
+
* violation with the thrown message \u2014 fail closed.
|
|
94
|
+
*/
|
|
95
|
+
export function runGuards(
|
|
96
|
+
input: any,
|
|
97
|
+
op: string,
|
|
98
|
+
actor: any = null,
|
|
99
|
+
): Violation[] {
|
|
100
|
+
const violations: Violation[] = [];
|
|
101
|
+
for (const c of MODEL_CONSTRAINTS) {
|
|
102
|
+
if (!matchesOp(c.on, op)) continue;
|
|
103
|
+
let passed = false;
|
|
104
|
+
let thrown: string | null = null;
|
|
105
|
+
try {
|
|
106
|
+
passed = c.guard(input, actor);
|
|
107
|
+
} catch (e: any) {
|
|
108
|
+
thrown = e?.message ?? String(e);
|
|
109
|
+
}
|
|
110
|
+
if (!passed) {
|
|
111
|
+
violations.push({
|
|
112
|
+
constraint: c.name,
|
|
113
|
+
scope: op,
|
|
114
|
+
source: c.source,
|
|
115
|
+
message: thrown
|
|
116
|
+
? \`Constraint "\${c.source}" could not be evaluated: \${thrown}\`
|
|
117
|
+
: \`Constraint "\${c.source}" failed\`,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return violations;
|
|
122
|
+
}
|
|
123
|
+
`;
|
|
124
|
+
}
|
|
125
|
+
function generateEmptyGuardsModule(modelName) {
|
|
126
|
+
return `/**
|
|
127
|
+
* Auto-generated by SpecVerse Phase 2 \u2014 no constraints declared on ${modelName}.
|
|
128
|
+
* Empty stub kept for symmetry; controllers gate their imports on this file.
|
|
129
|
+
*/
|
|
130
|
+
|
|
131
|
+
export interface Violation {
|
|
132
|
+
constraint: string;
|
|
133
|
+
scope: string;
|
|
134
|
+
source: string;
|
|
135
|
+
message: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export const MODEL_CONSTRAINTS: any[] = [];
|
|
139
|
+
|
|
140
|
+
export function matchesOp(_constraintOn: string[], _op: string): boolean {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function runGuards(_input: any, _op: string, _actor: any = null): Violation[] {
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
`;
|
|
148
|
+
}
|
|
149
|
+
export {
|
|
150
|
+
generatePrismaGuards as default
|
|
151
|
+
};
|