@specverse/engines 6.66.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 (42) hide show
  1. package/dist/inference/index.d.ts +1 -1
  2. package/dist/inference/index.d.ts.map +1 -1
  3. package/dist/inference/index.js +1 -1
  4. package/dist/inference/index.js.map +1 -1
  5. package/dist/inference/quint-transpiler.d.ts +18 -0
  6. package/dist/inference/quint-transpiler.d.ts.map +1 -1
  7. package/dist/inference/quint-transpiler.js +32 -0
  8. package/dist/inference/quint-transpiler.js.map +1 -1
  9. package/dist/libs/instance-factories/controllers/templates/fastify/routes-generator.js +14 -5
  10. package/dist/libs/instance-factories/services/mongodb-native-services.yaml +10 -0
  11. package/dist/libs/instance-factories/services/postgres-native-services.yaml +10 -0
  12. package/dist/libs/instance-factories/services/prisma-services.yaml +10 -0
  13. package/dist/libs/instance-factories/services/templates/_shared/guards-generator.js +209 -0
  14. package/dist/libs/instance-factories/services/templates/mongodb-native/controller-generator.js +110 -23
  15. package/dist/libs/instance-factories/services/templates/postgres-native/controller-generator.js +104 -22
  16. package/dist/libs/instance-factories/services/templates/prisma/controller-generator.js +133 -23
  17. package/dist/libs/instance-factories/services/templates/prisma/guards-generator.js +151 -0
  18. package/dist/parser/convention-processor.d.ts +44 -1
  19. package/dist/parser/convention-processor.d.ts.map +1 -1
  20. package/dist/parser/convention-processor.js +175 -1
  21. package/dist/parser/convention-processor.js.map +1 -1
  22. package/dist/parser/types/ast.d.ts +1 -1
  23. package/dist/parser/types/ast.d.ts.map +1 -1
  24. package/dist/parser/unified-parser.d.ts.map +1 -1
  25. package/dist/parser/unified-parser.js +25 -2
  26. package/dist/parser/unified-parser.js.map +1 -1
  27. package/dist/realize/index.d.ts.map +1 -1
  28. package/dist/realize/index.js +17 -0
  29. package/dist/realize/index.js.map +1 -1
  30. package/libs/instance-factories/controllers/templates/fastify/__tests__/actor-wiring.test.ts +80 -0
  31. package/libs/instance-factories/controllers/templates/fastify/routes-generator.ts +14 -5
  32. package/libs/instance-factories/services/mongodb-native-services.yaml +10 -0
  33. package/libs/instance-factories/services/postgres-native-services.yaml +10 -0
  34. package/libs/instance-factories/services/prisma-services.yaml +10 -0
  35. package/libs/instance-factories/services/templates/_shared/guards-generator.ts +296 -0
  36. package/libs/instance-factories/services/templates/mongodb-native/__tests__/controller-with-constraints.test.ts +192 -0
  37. package/libs/instance-factories/services/templates/mongodb-native/controller-generator.ts +144 -23
  38. package/libs/instance-factories/services/templates/postgres-native/__tests__/controller-with-constraints.test.ts +192 -0
  39. package/libs/instance-factories/services/templates/postgres-native/controller-generator.ts +130 -22
  40. package/libs/instance-factories/services/templates/prisma/__tests__/controller-with-constraints.test.ts +261 -0
  41. package/libs/instance-factories/services/templates/prisma/controller-generator.ts +186 -22
  42. package/package.json +1 -1
@@ -46,6 +46,14 @@ export default function generatePrismaController(context: TemplateContext): stri
46
46
  // Generate custom actions and collect AI behavior stubs
47
47
  const customActions = generateCustomActions(controller, modelName, modelVar);
48
48
 
49
+ // Phase 2 — the guards module only exists for models with declared
50
+ // constraints, so import + invocation are gated on this flag. Computed
51
+ // up-front so generateValidateMethod / generateEvolveMethod /
52
+ // generateDeleteMethod can emit the runConstraintGuards call.
53
+ const hasConstraints =
54
+ Array.isArray((model as any).constraints) &&
55
+ (model as any).constraints.length > 0;
56
+
49
57
  // Build the class body first, then introspect to decide which
50
58
  // top-of-file declarations are actually needed. Controllers with no
51
59
  // CURVED ops and no prisma-using custom actions (e.g. a controller
@@ -54,12 +62,12 @@ export default function generatePrismaController(context: TemplateContext): stri
54
62
  // unconditionally produced TS6133 unused-decl errors at every
55
63
  // realize run. Gating on actual body content drops those.
56
64
  const classBody = [
57
- generateValidateMethod(model, modelName),
58
- curedOps.create ? generateCreateMethod(model, modelName, modelVar, prismaDelegate, controller, allModels) : '',
65
+ generateValidateMethod(model, modelName, hasConstraints),
66
+ curedOps.create ? generateCreateMethod(model, modelName, modelVar, prismaDelegate, controller, allModels, hasConstraints) : '',
59
67
  curedOps.retrieve ? generateRetrieveMethod(model, modelName, modelVar, prismaDelegate) : '',
60
- curedOps.update ? generateUpdateMethod(model, modelName, modelVar, prismaDelegate, controller, allModels) : '',
61
- curedOps.evolve ? generateEvolveMethod(model, modelName, modelVar, prismaDelegate, controller) : '',
62
- curedOps.delete ? generateDeleteMethod(model, modelName, modelVar, prismaDelegate, controller) : '',
68
+ curedOps.update ? generateUpdateMethod(model, modelName, modelVar, prismaDelegate, controller, allModels, hasConstraints) : '',
69
+ curedOps.evolve ? generateEvolveMethod(model, modelName, modelVar, prismaDelegate, controller, hasConstraints) : '',
70
+ curedOps.delete ? generateDeleteMethod(model, modelName, modelVar, prismaDelegate, controller, hasConstraints) : '',
63
71
  customActions.code,
64
72
  ].filter(Boolean).join('\n ');
65
73
 
@@ -78,6 +86,9 @@ export default function generatePrismaController(context: TemplateContext): stri
78
86
  usesPrisma ? `import { PrismaClient } from '@prisma/client';` : '',
79
87
  usesEventBus ? `import { eventBus } from '../events/eventBus.js';` : '',
80
88
  usesAiBehaviors ? `import * as aiBehaviors from '../behaviors/${modelName}Controller.ai.js';` : '',
89
+ hasConstraints
90
+ ? `import { runGuards as runConstraintGuards } from './${modelName}.guards.js';`
91
+ : '',
81
92
  ].filter(Boolean).join('\n');
82
93
 
83
94
  const declarations = [
@@ -112,20 +123,60 @@ export default ${modelVar}Controller;
112
123
 
113
124
  /**
114
125
  * Generate validate method (unified validation for all operations)
126
+ *
127
+ * Phase 2 — when the model has declared constraints, the body ALSO calls
128
+ * `runConstraintGuards(data, op, actor)` from the per-model `.guards.ts`
129
+ * module and appends any constraint violation messages to the errors
130
+ * array. `op:` shape supports the wider CURVED vocabulary including
131
+ * `delete` and `evolve.<transition>`.
115
132
  */
116
- function generateValidateMethod(model: any, modelName: string): string {
133
+ function generateValidateMethod(model: any, modelName: string, hasConstraints: boolean): string {
134
+ // The `_context.operation` union widens for Phase 2 so the guards
135
+ // module's matchesOp() can match evolve.<transition> and delete records.
136
+ const opTypeUnion = hasConstraints
137
+ ? `'create' | 'update' | 'evolve' | 'delete' | \`evolve.\${string}\``
138
+ : `'create' | 'update' | 'evolve'`;
139
+
140
+ const constraintsCheck = hasConstraints
141
+ ? `
142
+ // Phase 2 — run model.constraints[] guards matching this operation.
143
+ // ctx carries a per-model query helper so subquery sugars (rewritten
144
+ // to async guards by guards-generator) can run their findFirst calls.
145
+ const __guardCtx = {
146
+ query: (modelName: string) => {
147
+ const delegate = (prisma as any)[modelName.charAt(0).toLowerCase() + modelName.slice(1)];
148
+ if (!delegate) return undefined;
149
+ return {
150
+ exists: async (predicate: (e: any) => boolean) => {
151
+ const all = await delegate.findMany();
152
+ return all.some(predicate);
153
+ },
154
+ };
155
+ },
156
+ };
157
+ const constraintViolations = await runConstraintGuards(_data, _context.operation, _actor, __guardCtx);
158
+ for (const v of constraintViolations) errors.push(v.message);`
159
+ : '';
160
+
161
+ // Phase 2 actor wiring — validate() ALWAYS accepts _actor (default null)
162
+ // even on unconstrained models, so route handlers can pass it unconditionally
163
+ // without per-model knowledge of whether constraints exist.
164
+ // Slice 15b — validate is async (it awaits runConstraintGuards which may
165
+ // execute subquery sugars). Callers must await this.validate(...).
166
+
117
167
  return `
118
168
  /**
119
169
  * Validate ${modelName} data
120
170
  * Unified validation method for all operations
121
171
  */
122
- public validate(
172
+ public async validate(
123
173
  _data: any,
124
- _context: { operation: 'create' | 'update' | 'evolve' }
125
- ): { valid: boolean; errors: string[] } {
174
+ _context: { operation: ${opTypeUnion} },
175
+ _actor: any = null
176
+ ): Promise<{ valid: boolean; errors: string[] }> {
126
177
  const errors: string[] = [];
127
178
 
128
- ${generateValidationLogic(model, '_data', '_context')}
179
+ ${generateValidationLogic(model, '_data', '_context')}${constraintsCheck}
129
180
 
130
181
  return {
131
182
  valid: errors.length === 0,
@@ -196,14 +247,28 @@ function generateValidationLogic(model: any, dataParam: string = '_data', contex
196
247
  /**
197
248
  * Generate create method
198
249
  */
199
- function generateCreateMethod(model: any, modelName: string, modelVar: string, prismaDelegate: string, controller: any, allModels?: any[]): string {
250
+ function generateCreateMethod(model: any, modelName: string, modelVar: string, prismaDelegate: string, controller: any, allModels?: any[], hasConstraints: boolean = false): string {
251
+ const belongsToLoad = hasConstraints ? generateBelongsToLoad(model, allModels) : '';
252
+ const validateSelf = belongsToLoad ? '__mergedSelf' : 'data';
253
+
200
254
  return `
201
255
  /**
202
256
  * Create a new ${modelName}
203
257
  */
204
- public async create(data: any): Promise<any> {
258
+ public async create(data: any, _actor: any = null): Promise<any> {
259
+ ${belongsToLoad ? `
260
+ // Phase 2 Slice 15 — Create-time relation loading. For each belongsTo
261
+ // FK present in input, load the related entity and merge into self
262
+ // so constraints traversing self.<rel>.<field> see the full related
263
+ // record (not just the FK id). Mirrors Slice 8's Update pattern but
264
+ // for the create path. Only fires when the model has declared
265
+ // constraints; one extra round-trip per FK in input.
266
+ const __loadedRels: Record<string, any> = {};
267
+ ${belongsToLoad}
268
+ const __mergedSelf = { ...data, ...__loadedRels };
269
+ ` : ''}
205
270
  // Validate input
206
- const validationResult = this.validate(data, { operation: 'create' });
271
+ const validationResult = await this.validate(${validateSelf}, { operation: 'create' }, _actor);
207
272
  if (!validationResult.valid) {
208
273
  throw new Error(\`Validation failed: \${validationResult.errors.join(', ')}\`);
209
274
  }
@@ -225,6 +290,52 @@ function generateCreateMethod(model: any, modelName: string, modelVar: string, p
225
290
  `;
226
291
  }
227
292
 
293
+ /**
294
+ * Phase 2 Slice 15 — emit `if (data.<fkField>) __loadedRels.<relName> = await prisma.<target>.findUnique(...)`
295
+ * statements for each belongsTo relationship. Used by create() to load
296
+ * related entities before constraint guards run.
297
+ *
298
+ * IDs are parsed via parseId() when the target model has an Integer id;
299
+ * UUID/String targets pass through as-is (parseId is the no-op identity
300
+ * function for those).
301
+ */
302
+ function generateBelongsToLoad(model: any, allModels?: any[]): string {
303
+ const rels = Array.isArray(model.relationships)
304
+ ? model.relationships
305
+ : Object.values(model.relationships || {});
306
+
307
+ const belongsToRels = (rels as any[]).filter(r => r.type === 'belongsTo');
308
+ if (belongsToRels.length === 0) return '';
309
+
310
+ const RESERVED_WORDS = 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']);
311
+
312
+ return belongsToRels.map((rel: any) => {
313
+ const relName = rel.name;
314
+ const targetName = rel.target;
315
+ const fkField = `${relName}Id`;
316
+ const targetVar = targetName.charAt(0).toLowerCase() + targetName.slice(1);
317
+ const targetDelegate = RESERVED_WORDS.has(targetVar) ? `prisma['${targetVar}']` : `prisma.${targetVar}`;
318
+
319
+ // Determine if target uses int IDs
320
+ let idExpr = `data.${fkField}`;
321
+ if (allModels) {
322
+ const targetModel = allModels.find((m: any) => m.name === targetName);
323
+ if (targetModel) {
324
+ const idAttr = (Array.isArray(targetModel.attributes) ? targetModel.attributes : Object.values(targetModel.attributes || {}))
325
+ .find((a: any) => a.name === 'id');
326
+ const idType = idAttr?.type || 'String';
327
+ if (idType === 'Integer' || idType === 'Int' || idType === 'Number') {
328
+ idExpr = `parseInt(data.${fkField}, 10)`;
329
+ }
330
+ }
331
+ }
332
+
333
+ return `if (data.${fkField}) {
334
+ __loadedRels.${relName} = await ${targetDelegate}.findUnique({ where: { id: ${idExpr} } });
335
+ }`;
336
+ }).join('\n ');
337
+ }
338
+
228
339
  /**
229
340
  * Generate retrieve method
230
341
  */
@@ -255,14 +366,26 @@ function generateRetrieveMethod(model: any, modelName: string, modelVar: string,
255
366
  /**
256
367
  * Generate update method
257
368
  */
258
- function generateUpdateMethod(model: any, modelName: string, modelVar: string, prismaDelegate: string, controller: any, allModels?: any[]): string {
369
+ function generateUpdateMethod(model: any, modelName: string, modelVar: string, prismaDelegate: string, controller: any, allModels?: any[], hasConstraints: boolean = false): string {
259
370
  return `
260
371
  /**
261
372
  * Update ${modelName}
262
373
  */
263
- public async update(id: string, data: any): Promise<any> {
374
+ public async update(id: string, data: any, _actor: any = null): Promise<any> {
375
+ ${hasConstraints ? `
376
+ // Phase 2 — Update self-from-DB: load the entity first and validate
377
+ // against the MERGED \`loaded + input\` shape so update-time constraints
378
+ // like \`self.poll.votingStatus == "open"\` see the full record even
379
+ // when the caller only sent a partial payload. Costs one extra DB
380
+ // round-trip; only fires for models with declared constraints.
381
+ const __existing = await ${prismaDelegate}.findUnique({ where: { id: parseId(id) }${generateIncludeRelationships(model)} });
382
+ if (!__existing) throw new Error('${modelName} not found');
383
+ const __merged = { ...__existing, ...data };
384
+ const validationResult = await this.validate(__merged, { operation: 'update' }, _actor);
385
+ ` : `
264
386
  // Validate input
265
- const validationResult = this.validate(data, { operation: 'update' });
387
+ const validationResult = await this.validate(data, { operation: 'update' }, _actor);
388
+ `}
266
389
  if (!validationResult.valid) {
267
390
  throw new Error(\`Validation failed: \${validationResult.errors.join(', ')}\`);
268
391
  }
@@ -296,7 +419,7 @@ function generateUpdateMethod(model: any, modelName: string, modelVar: string, p
296
419
  /**
297
420
  * Generate evolve method (lifecycle-aware updates)
298
421
  */
299
- function generateEvolveMethod(model: any, modelName: string, modelVar: string, prismaDelegate: string, controller: any): string {
422
+ function generateEvolveMethod(model: any, modelName: string, modelVar: string, prismaDelegate: string, controller: any, hasConstraints: boolean = false): string {
300
423
  // Extract lifecycle — handle both array and object format
301
424
  const lifecycles = Array.isArray(model.lifecycles) ? model.lifecycles :
302
425
  (model.lifecycles ? Object.entries(model.lifecycles).map(([name, lc]: [string, any]) => ({ name, ...lc })) : []);
@@ -307,6 +430,22 @@ function generateEvolveMethod(model: any, modelName: string, modelVar: string, p
307
430
  // Build transition map using shared spec-rules
308
431
  const validTransitions = lifecycle ? buildTransitionMap(lifecycle) : {};
309
432
 
433
+ // Phase 2 — reverse map (currentState -> targetState -> actionName) so
434
+ // evolve() can pass the correct `evolve.<actionName>` op to validate.
435
+ // Falls through to the targetState string for shorthand-flow lifecycles
436
+ // that don't have named transition actions.
437
+ const evolveOpsMap: Record<string, Record<string, string>> = {};
438
+ if (lifecycle?.transitions) {
439
+ for (const [actionName, t] of Object.entries(lifecycle.transitions as Record<string, any>)) {
440
+ const from = (t as any).from;
441
+ const to = (t as any).to;
442
+ if (typeof from === 'string' && typeof to === 'string') {
443
+ evolveOpsMap[from] = evolveOpsMap[from] ?? {};
444
+ evolveOpsMap[from][to] = actionName;
445
+ }
446
+ }
447
+ }
448
+
310
449
  return `
311
450
  /**
312
451
  * Evolve ${modelName} through lifecycle "${lifecycleName}"
@@ -317,7 +456,7 @@ function generateEvolveMethod(model: any, modelName: string, modelVar: string, p
317
456
  * runtime's useTransitionStateMutation always sends the former; the
318
457
  * realized smoke-parity script can send either.
319
458
  */
320
- public async evolve(id: string, data: any): Promise<any> {
459
+ public async evolve(id: string, data: any, _actor: any = null): Promise<any> {
321
460
  // Get current record to check lifecycle state
322
461
  const current = await ${prismaDelegate}.findUnique({ where: { id: parseId(id) } });
323
462
  if (!current) {
@@ -342,7 +481,22 @@ function generateEvolveMethod(model: any, modelName: string, modelVar: string, p
342
481
  throw new Error(\`Invalid transition: \${currentState} → \${targetState}. Allowed: \${allowed.join(', ') || 'none'}\`);
343
482
  }
344
483
  ` : ''}
345
-
484
+ ${hasConstraints ? `
485
+ // Phase 2 — resolve the transition's action name so constraints scoped
486
+ // to \`on: 'evolve.<action>'\` match correctly. EVOLVE_OPS is baked at
487
+ // codegen from the lifecycle definition.
488
+ const EVOLVE_OPS: Record<string, Record<string, string>> = ${JSON.stringify(evolveOpsMap)};
489
+ const currentStateForOp = (current as any)[targetLifecycle];
490
+ const actionName = EVOLVE_OPS[currentStateForOp]?.[targetState] ?? targetState;
491
+ const evolveValidation = await this.validate(
492
+ { ...data, ...current, [targetLifecycle]: targetState },
493
+ { operation: \`evolve.\${actionName}\` as any },
494
+ _actor
495
+ );
496
+ if (!evolveValidation.valid) {
497
+ throw new Error(\`Validation failed: \${evolveValidation.errors.join(', ')}\`);
498
+ }
499
+ ` : ''}
346
500
  // Build the Prisma update payload — only the lifecycle column
347
501
  // changes. Strips toState/lifecycleName/state so Prisma doesn't
348
502
  // reject unknown fields.
@@ -365,15 +519,25 @@ function generateEvolveMethod(model: any, modelName: string, modelVar: string, p
365
519
  /**
366
520
  * Generate delete method
367
521
  */
368
- function generateDeleteMethod(model: any, modelName: string, modelVar: string, prismaDelegate: string, controller: any): string {
522
+ function generateDeleteMethod(model: any, modelName: string, modelVar: string, prismaDelegate: string, controller: any, hasConstraints: boolean = false): string {
369
523
  return `
370
524
  /**
371
525
  * Delete ${modelName}
372
526
  */
373
- public async delete(id: string): Promise<void> {
527
+ public async delete(id: string, _actor: any = null): Promise<void> {
374
528
  // Get record before deletion for event
375
529
  const ${modelVar} = await ${prismaDelegate}.findUnique({ where: { id: parseId(id) } });
376
-
530
+ ${hasConstraints ? `
531
+ // Phase 2 — run delete-scoped constraint guards against the loaded
532
+ // record. \`self\` is the entity being deleted (loaded above), giving
533
+ // delete-time constraints access to the entity's current field values.
534
+ if (${modelVar}) {
535
+ const deleteValidation = await this.validate(${modelVar}, { operation: 'delete' }, _actor);
536
+ if (!deleteValidation.valid) {
537
+ throw new Error(\`Validation failed: \${deleteValidation.errors.join(', ')}\`);
538
+ }
539
+ }
540
+ ` : ''}
377
541
  await ${prismaDelegate}.delete({
378
542
  where: { id: parseId(id) }
379
543
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specverse/engines",
3
- "version": "6.66.0",
3
+ "version": "6.75.0",
4
4
  "description": "SpecVerse toolchain — parser, inference, realize, generators, AI, registry, bundles",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",