@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.
Files changed (46) hide show
  1. package/dist/ai/providers/claude-cli.d.ts +14 -0
  2. package/dist/ai/providers/claude-cli.d.ts.map +1 -1
  3. package/dist/ai/providers/claude-cli.js +167 -17
  4. package/dist/ai/providers/claude-cli.js.map +1 -1
  5. package/dist/inference/index.d.ts +1 -1
  6. package/dist/inference/index.d.ts.map +1 -1
  7. package/dist/inference/index.js +1 -1
  8. package/dist/inference/index.js.map +1 -1
  9. package/dist/inference/quint-transpiler.d.ts +18 -0
  10. package/dist/inference/quint-transpiler.d.ts.map +1 -1
  11. package/dist/inference/quint-transpiler.js +32 -0
  12. package/dist/inference/quint-transpiler.js.map +1 -1
  13. package/dist/libs/instance-factories/controllers/templates/fastify/routes-generator.js +14 -5
  14. package/dist/libs/instance-factories/services/mongodb-native-services.yaml +10 -0
  15. package/dist/libs/instance-factories/services/postgres-native-services.yaml +10 -0
  16. package/dist/libs/instance-factories/services/prisma-services.yaml +10 -0
  17. package/dist/libs/instance-factories/services/templates/_shared/guards-generator.js +209 -0
  18. package/dist/libs/instance-factories/services/templates/mongodb-native/controller-generator.js +110 -23
  19. package/dist/libs/instance-factories/services/templates/postgres-native/controller-generator.js +104 -22
  20. package/dist/libs/instance-factories/services/templates/prisma/controller-generator.js +133 -23
  21. package/dist/libs/instance-factories/services/templates/prisma/guards-generator.js +151 -0
  22. package/dist/parser/convention-processor.d.ts +44 -1
  23. package/dist/parser/convention-processor.d.ts.map +1 -1
  24. package/dist/parser/convention-processor.js +175 -1
  25. package/dist/parser/convention-processor.js.map +1 -1
  26. package/dist/parser/types/ast.d.ts +1 -1
  27. package/dist/parser/types/ast.d.ts.map +1 -1
  28. package/dist/parser/unified-parser.d.ts.map +1 -1
  29. package/dist/parser/unified-parser.js +25 -2
  30. package/dist/parser/unified-parser.js.map +1 -1
  31. package/dist/realize/index.d.ts.map +1 -1
  32. package/dist/realize/index.js +17 -0
  33. package/dist/realize/index.js.map +1 -1
  34. package/libs/instance-factories/controllers/templates/fastify/__tests__/actor-wiring.test.ts +80 -0
  35. package/libs/instance-factories/controllers/templates/fastify/routes-generator.ts +14 -5
  36. package/libs/instance-factories/services/mongodb-native-services.yaml +10 -0
  37. package/libs/instance-factories/services/postgres-native-services.yaml +10 -0
  38. package/libs/instance-factories/services/prisma-services.yaml +10 -0
  39. package/libs/instance-factories/services/templates/_shared/guards-generator.ts +296 -0
  40. package/libs/instance-factories/services/templates/mongodb-native/__tests__/controller-with-constraints.test.ts +192 -0
  41. package/libs/instance-factories/services/templates/mongodb-native/controller-generator.ts +144 -23
  42. package/libs/instance-factories/services/templates/postgres-native/__tests__/controller-with-constraints.test.ts +192 -0
  43. package/libs/instance-factories/services/templates/postgres-native/controller-generator.ts +130 -22
  44. package/libs/instance-factories/services/templates/prisma/__tests__/controller-with-constraints.test.ts +261 -0
  45. package/libs/instance-factories/services/templates/prisma/controller-generator.ts +186 -22
  46. package/package.json +1 -1
@@ -15,12 +15,13 @@ function generatePgNativeController(context) {
15
15
  Object.assign(modelRegistry, models);
16
16
  }
17
17
  const customActions = generateCustomActions(controller, modelRegistry);
18
- const validate = generateValidateMethod(model, modelName);
19
- const create = curedOps.create ? generateCreateMethod(modelName, modelVar) : "";
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: 'create' | 'update' | 'evolve' }
75
- ): { valid: boolean; errors: string[] } {
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
- const validation = this.validate(data, { operation: 'create' });
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
- const validation = this.validate(data, { operation: 'update' });
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: 'create' | 'update' | 'evolve' }
74
- ): { valid: boolean; errors: string[] } {
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(data, { operation: 'create' });
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
+ };