@specverse/engines 6.66.0 → 6.76.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 (53) 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/inference/ui-contracts/index.d.ts.map +1 -1
  10. package/dist/inference/ui-contracts/index.js +2 -0
  11. package/dist/inference/ui-contracts/index.js.map +1 -1
  12. package/dist/inference/ui-contracts/rules/_shared.d.ts +8 -0
  13. package/dist/inference/ui-contracts/rules/_shared.d.ts.map +1 -1
  14. package/dist/inference/ui-contracts/rules/_shared.js +20 -0
  15. package/dist/inference/ui-contracts/rules/_shared.js.map +1 -1
  16. package/dist/inference/ui-contracts/rules/belongsto-shows-name-in-list.d.ts +29 -0
  17. package/dist/inference/ui-contracts/rules/belongsto-shows-name-in-list.d.ts.map +1 -0
  18. package/dist/inference/ui-contracts/rules/belongsto-shows-name-in-list.js +88 -0
  19. package/dist/inference/ui-contracts/rules/belongsto-shows-name-in-list.js.map +1 -0
  20. package/dist/libs/instance-factories/controllers/templates/fastify/routes-generator.js +14 -5
  21. package/dist/libs/instance-factories/services/mongodb-native-services.yaml +10 -0
  22. package/dist/libs/instance-factories/services/postgres-native-services.yaml +10 -0
  23. package/dist/libs/instance-factories/services/prisma-services.yaml +10 -0
  24. package/dist/libs/instance-factories/services/templates/_shared/guards-generator.js +209 -0
  25. package/dist/libs/instance-factories/services/templates/mongodb-native/controller-generator.js +110 -23
  26. package/dist/libs/instance-factories/services/templates/postgres-native/controller-generator.js +104 -22
  27. package/dist/libs/instance-factories/services/templates/prisma/controller-generator.js +133 -23
  28. package/dist/libs/instance-factories/services/templates/prisma/guards-generator.js +151 -0
  29. package/dist/parser/convention-processor.d.ts +44 -1
  30. package/dist/parser/convention-processor.d.ts.map +1 -1
  31. package/dist/parser/convention-processor.js +175 -1
  32. package/dist/parser/convention-processor.js.map +1 -1
  33. package/dist/parser/types/ast.d.ts +1 -1
  34. package/dist/parser/types/ast.d.ts.map +1 -1
  35. package/dist/parser/unified-parser.d.ts.map +1 -1
  36. package/dist/parser/unified-parser.js +25 -2
  37. package/dist/parser/unified-parser.js.map +1 -1
  38. package/dist/realize/index.d.ts.map +1 -1
  39. package/dist/realize/index.js +17 -0
  40. package/dist/realize/index.js.map +1 -1
  41. package/libs/instance-factories/controllers/templates/fastify/__tests__/actor-wiring.test.ts +80 -0
  42. package/libs/instance-factories/controllers/templates/fastify/routes-generator.ts +14 -5
  43. package/libs/instance-factories/services/mongodb-native-services.yaml +10 -0
  44. package/libs/instance-factories/services/postgres-native-services.yaml +10 -0
  45. package/libs/instance-factories/services/prisma-services.yaml +10 -0
  46. package/libs/instance-factories/services/templates/_shared/guards-generator.ts +296 -0
  47. package/libs/instance-factories/services/templates/mongodb-native/__tests__/controller-with-constraints.test.ts +192 -0
  48. package/libs/instance-factories/services/templates/mongodb-native/controller-generator.ts +144 -23
  49. package/libs/instance-factories/services/templates/postgres-native/__tests__/controller-with-constraints.test.ts +192 -0
  50. package/libs/instance-factories/services/templates/postgres-native/controller-generator.ts +130 -22
  51. package/libs/instance-factories/services/templates/prisma/__tests__/controller-with-constraints.test.ts +261 -0
  52. package/libs/instance-factories/services/templates/prisma/controller-generator.ts +186 -22
  53. package/package.json +1 -1
@@ -49,12 +49,17 @@ export default function generatePgNativeController(context: TemplateContext): st
49
49
 
50
50
  const customActions = generateCustomActions(controller, modelRegistry);
51
51
 
52
- const validate = generateValidateMethod(model, modelName);
53
- const create = curedOps.create ? generateCreateMethod(modelName, modelVar) : '';
52
+ // Phase 2 — guards module gating (see prisma + mongo for the same pattern).
53
+ const hasConstraints =
54
+ Array.isArray((model as any).constraints) &&
55
+ (model as any).constraints.length > 0;
56
+
57
+ const validate = generateValidateMethod(model, modelName, hasConstraints);
58
+ const create = curedOps.create ? generateCreateMethod(model, modelName, modelVar, hasConstraints) : '';
54
59
  const retrieve = curedOps.retrieve ? generateRetrieveMethod(modelName, modelVar) : '';
55
- const update = curedOps.update ? generateUpdateMethod(modelName, modelVar) : '';
56
- const evolve = curedOps.evolve ? generateEvolveMethod(model, modelName, modelVar) : '';
57
- const del = curedOps.delete ? generateDeleteMethod(modelName, modelVar) : '';
60
+ const update = curedOps.update ? generateUpdateMethod(modelName, modelVar, hasConstraints) : '';
61
+ const evolve = curedOps.evolve ? generateEvolveMethod(model, modelName, modelVar, hasConstraints) : '';
62
+ const del = curedOps.delete ? generateDeleteMethod(modelName, modelVar, hasConstraints) : '';
58
63
 
59
64
  const hasEventPublishing =
60
65
  curedOps.create || curedOps.update || curedOps.evolve || curedOps.delete;
@@ -83,6 +88,7 @@ export default function generatePgNativeController(context: TemplateContext): st
83
88
  import { ${helperImports} } from '../db/pgClient.js';
84
89
  ${hasEventPublishing || customActions.needsAiBehaviors ? `import { eventBus } from '../events/eventBus.js';` : ''}
85
90
  ${customActions.needsAiBehaviors ? `import * as aiBehaviors from '../behaviors/${controllerName}.ai.js';` : ''}
91
+ ${hasConstraints ? `import { runGuards as runConstraintGuards } from './${modelName}.guards.js';` : ''}
86
92
 
87
93
  const TABLE_NAME = '${table}';
88
94
 
@@ -111,17 +117,45 @@ function tableName(model: any): string {
111
117
  return model.name.toLowerCase() + 's';
112
118
  }
113
119
 
114
- function generateValidateMethod(model: any, modelName: string): string {
120
+ /**
121
+ * Phase 2 — when the model has declared constraints, validate() ALSO
122
+ * calls runConstraintGuards. Op union widens to 'delete' + evolve.<X>.
123
+ */
124
+ function generateValidateMethod(model: any, modelName: string, hasConstraints: boolean): string {
125
+ const opTypeUnion = hasConstraints
126
+ ? `'create' | 'update' | 'evolve' | 'delete' | \`evolve.\${string}\``
127
+ : `'create' | 'update' | 'evolve'`;
128
+
129
+ const constraintsCheck = hasConstraints
130
+ ? `
131
+ // Phase 2 — run model.constraints[] guards matching this operation.
132
+ // Slice 15b — ctx carries a per-model query helper for subquery sugars.
133
+ const __guardCtx = {
134
+ query: (modelName: string) => ({
135
+ exists: async (predicate: (e: any) => boolean) => {
136
+ const all = await findAll(modelName);
137
+ return all.some(predicate);
138
+ },
139
+ }),
140
+ };
141
+ const constraintViolations = await runConstraintGuards(_data, _context.operation, _actor, __guardCtx);
142
+ for (const v of constraintViolations) errors.push(v.message);`
143
+ : '';
144
+
145
+ // Phase 2 actor wiring + Slice 15b — validate is async (awaits subquery
146
+ // execution in guard ctx); callers must await this.validate(...).
147
+
115
148
  return `
116
149
  /**
117
- * Validate ${modelName} data — runs before create / update / evolve.
150
+ * Validate ${modelName} data — runs before create / update / evolve / delete.
118
151
  */
119
- public validate(
152
+ public async validate(
120
153
  _data: any,
121
- _context: { operation: 'create' | 'update' | 'evolve' }
122
- ): { valid: boolean; errors: string[] } {
154
+ _context: { operation: ${opTypeUnion} },
155
+ _actor: any = null
156
+ ): Promise<{ valid: boolean; errors: string[] }> {
123
157
  const errors: string[] = [];
124
- ${generateValidationLogic(model)}
158
+ ${generateValidationLogic(model)}${constraintsCheck}
125
159
  return { valid: errors.length === 0, errors };
126
160
  }
127
161
  `;
@@ -152,13 +186,24 @@ function generateValidationLogic(model: any): string {
152
186
  return out.join('\n') || ' // No validation rules defined';
153
187
  }
154
188
 
155
- function generateCreateMethod(modelName: string, modelVar: string): string {
189
+ function generateCreateMethod(model: any, modelName: string, modelVar: string, hasConstraints: boolean = false): string {
190
+ const belongsToLoad = hasConstraints ? generatePgBelongsToLoad(model) : '';
191
+ const validateSelf = belongsToLoad ? '__mergedSelf' : 'data';
192
+
156
193
  return `
157
194
  /**
158
195
  * Create a new ${modelName}.
159
196
  */
160
- public async create(data: any): Promise<any> {
161
- const validation = this.validate(data, { operation: 'create' });
197
+ public async create(data: any, _actor: any = null): Promise<any> {
198
+ ${belongsToLoad ? `
199
+ // Phase 2 Slice 15 — Create-time relation loading. Mirrors prisma's
200
+ // pattern: for each belongsTo FK in input, load the related row and
201
+ // merge into self for constraint guards.
202
+ const __loadedRels: Record<string, any> = {};
203
+ ${belongsToLoad}
204
+ const __mergedSelf = { ...data, ...__loadedRels };
205
+ ` : ''}
206
+ const validation = await this.validate(${validateSelf}, { operation: 'create' }, _actor);
162
207
  if (!validation.valid) throw new Error(\`Validation failed: \${validation.errors.join(', ')}\`);
163
208
 
164
209
  const ${modelVar} = await insertOne(TABLE_NAME, { ...data });
@@ -169,6 +214,25 @@ function generateCreateMethod(modelName: string, modelVar: string): string {
169
214
  `;
170
215
  }
171
216
 
217
+ function generatePgBelongsToLoad(model: any): string {
218
+ const rels = Array.isArray(model.relationships)
219
+ ? model.relationships
220
+ : Object.values(model.relationships || {});
221
+ const belongsToRels = (rels as any[]).filter(r => r.type === 'belongsTo');
222
+ if (belongsToRels.length === 0) return '';
223
+
224
+ return belongsToRels.map((rel: any) => {
225
+ const relName = rel.name;
226
+ const targetName = rel.target;
227
+ const fkField = `${relName}Id`;
228
+ // Postgres table names follow the same casing convention as the model.
229
+ // findOneByField('Vote', 'id', x) loads from the "Vote" table.
230
+ return `if (data.${fkField}) {
231
+ __loadedRels.${relName} = await findOneByField('${targetName}', 'id', data.${fkField});
232
+ }`;
233
+ }).join('\n ');
234
+ }
235
+
172
236
  function generateRetrieveMethod(modelName: string, _modelVar: string): string {
173
237
  return `
174
238
  /**
@@ -193,13 +257,22 @@ function generateRetrieveMethod(modelName: string, _modelVar: string): string {
193
257
  `;
194
258
  }
195
259
 
196
- function generateUpdateMethod(modelName: string, modelVar: string): string {
260
+ function generateUpdateMethod(modelName: string, modelVar: string, hasConstraints: boolean = false): string {
197
261
  return `
198
262
  /**
199
263
  * Update ${modelName}.
200
264
  */
201
- public async update(id: string, data: any): Promise<any> {
202
- const validation = this.validate(data, { operation: 'update' });
265
+ public async update(id: string, data: any, _actor: any = null): Promise<any> {
266
+ ${hasConstraints ? `
267
+ // Phase 2 — Update self-from-DB. Load + merge before validate so
268
+ // update-time constraints see the full entity, not just partial input.
269
+ const __existing = await findOneByField(TABLE_NAME, 'id', id);
270
+ if (!__existing) throw new Error('${modelName} not found');
271
+ const __merged = { ...__existing, ...data };
272
+ const validation = await this.validate(__merged, { operation: 'update' }, _actor);
273
+ ` : `
274
+ const validation = await this.validate(data, { operation: 'update' }, _actor);
275
+ `}
203
276
  if (!validation.valid) throw new Error(\`Validation failed: \${validation.errors.join(', ')}\`);
204
277
 
205
278
  // Strip nested objects + id — only scalar fields are written.
@@ -221,7 +294,7 @@ function generateUpdateMethod(modelName: string, modelVar: string): string {
221
294
  `;
222
295
  }
223
296
 
224
- function generateEvolveMethod(model: any, modelName: string, modelVar: string): string {
297
+ function generateEvolveMethod(model: any, modelName: string, modelVar: string, hasConstraints: boolean = false): string {
225
298
  const lifecycles = Array.isArray(model.lifecycles)
226
299
  ? model.lifecycles
227
300
  : (model.lifecycles ? Object.entries(model.lifecycles).map(([name, lc]: [string, any]) => ({ name, ...lc })) : []);
@@ -235,12 +308,25 @@ function generateEvolveMethod(model: any, modelName: string, modelVar: string):
235
308
  ...Object.values(validTransitions).flat(),
236
309
  ]));
237
310
 
311
+ // Phase 2 — reverse map (currentState -> targetState -> actionName).
312
+ const evolveOpsMap: Record<string, Record<string, string>> = {};
313
+ if (lifecycle?.transitions) {
314
+ for (const [actionName, t] of Object.entries(lifecycle.transitions as Record<string, any>)) {
315
+ const from = (t as any).from;
316
+ const to = (t as any).to;
317
+ if (typeof from === 'string' && typeof to === 'string') {
318
+ evolveOpsMap[from] = evolveOpsMap[from] ?? {};
319
+ evolveOpsMap[from][to] = actionName;
320
+ }
321
+ }
322
+ }
323
+
238
324
  return `
239
325
  /**
240
326
  * Evolve ${modelName} through lifecycle "${lifecycleName}"
241
327
  * States: ${states.join(' → ') || '(none declared)'}
242
328
  */
243
- public async evolve(id: string, data: any): Promise<any> {
329
+ public async evolve(id: string, data: any, _actor: any = null): Promise<any> {
244
330
  const current = await findOneByField(TABLE_NAME, 'id', id);
245
331
  if (!current) throw new Error('${modelName} not found');
246
332
 
@@ -256,7 +342,20 @@ function generateEvolveMethod(model: any, modelName: string, modelVar: string):
256
342
  throw new Error(\`Invalid transition: \${currentState} → \${targetState}. Allowed: \${allowed.join(', ') || 'none'}\`);
257
343
  }
258
344
  ` : ''}
259
-
345
+ ${hasConstraints ? `
346
+ // Phase 2 — resolve transition action name + call validate with evolve.<action>.
347
+ const EVOLVE_OPS: Record<string, Record<string, string>> = ${JSON.stringify(evolveOpsMap)};
348
+ const currentStateForOp = (current as any)[targetLifecycle];
349
+ const actionName = EVOLVE_OPS[currentStateForOp]?.[targetState] ?? targetState;
350
+ const evolveValidation = await this.validate(
351
+ { ...data, ...current, [targetLifecycle]: targetState },
352
+ { operation: \`evolve.\${actionName}\` as any },
353
+ _actor
354
+ );
355
+ if (!evolveValidation.valid) {
356
+ throw new Error(\`Validation failed: \${evolveValidation.errors.join(', ')}\`);
357
+ }
358
+ ` : ''}
260
359
  await updateOneById(TABLE_NAME, id, { [targetLifecycle]: targetState });
261
360
  const ${modelVar} = await findOneByField(TABLE_NAME, 'id', id);
262
361
  if (!${modelVar}) throw new Error('${modelName} not found after evolve');
@@ -267,13 +366,22 @@ function generateEvolveMethod(model: any, modelName: string, modelVar: string):
267
366
  `;
268
367
  }
269
368
 
270
- function generateDeleteMethod(modelName: string, modelVar: string): string {
369
+ function generateDeleteMethod(modelName: string, modelVar: string, hasConstraints: boolean = false): string {
271
370
  return `
272
371
  /**
273
372
  * Delete ${modelName}.
274
373
  */
275
- public async delete(id: string): Promise<void> {
374
+ public async delete(id: string, _actor: any = null): Promise<void> {
276
375
  const ${modelVar} = await findOneByField(TABLE_NAME, 'id', id);
376
+ ${hasConstraints ? `
377
+ // Phase 2 — run delete-scoped constraint guards against the loaded record.
378
+ if (${modelVar}) {
379
+ const deleteValidation = await this.validate(${modelVar}, { operation: 'delete' }, _actor);
380
+ if (!deleteValidation.valid) {
381
+ throw new Error(\`Validation failed: \${deleteValidation.errors.join(', ')}\`);
382
+ }
383
+ }
384
+ ` : ''}
277
385
  await deleteOneById(TABLE_NAME, id);
278
386
  if (${modelVar}) {
279
387
  await eventBus.publish('${modelName}Deleted', { ...${modelVar}, timestamp: new Date().toISOString() } as any);
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Phase 2 — prisma controller-generator emits guards import + extended
3
+ * validate() body when the model has declared constraints.
4
+ *
5
+ * This is a smoke-shape test: we assert STRINGS in the generated controller
6
+ * source, not runtime behavior of the controller. The runtime behavior is
7
+ * covered by the end-to-end realize integration test (Task #8).
8
+ */
9
+
10
+ import { describe, it, expect } from 'vitest';
11
+ import generatePrismaController from '../controller-generator.js';
12
+ import type { ModelConstraintSpec } from '@specverse/types';
13
+
14
+ function buildConstrainedModel(): any {
15
+ const expanded: ModelConstraintSpec = {
16
+ on: ['create'],
17
+ requires: {
18
+ type: 'guard',
19
+ name: 'Vote_create_requires',
20
+ body: 'self.poll.votingStatus == "open"',
21
+ params: 'self: Vote, actor: User',
22
+ source: {
23
+ convention: 'compound',
24
+ entity: 'constraints',
25
+ input: 'self.poll.votingStatus == "open"',
26
+ },
27
+ },
28
+ source: {
29
+ authorOn: 'create',
30
+ authorRequires: 'self.poll.votingStatus == "open"',
31
+ },
32
+ };
33
+ return {
34
+ name: 'Vote',
35
+ attributes: [
36
+ { name: 'id', type: 'UUID', required: true, unique: true, category: 'metadata', auto: 'uuid4' },
37
+ { name: 'choice', type: 'String', required: true, unique: false, category: 'business' },
38
+ ],
39
+ relationships: [],
40
+ lifecycles: [],
41
+ behaviors: {},
42
+ constraints: [expanded],
43
+ };
44
+ }
45
+
46
+ function buildUnconstrainedModel(): any {
47
+ return {
48
+ name: 'Comment',
49
+ attributes: [
50
+ { name: 'id', type: 'UUID', required: true, unique: true, category: 'metadata', auto: 'uuid4' },
51
+ { name: 'text', type: 'String', required: true, unique: false, category: 'business' },
52
+ ],
53
+ relationships: [],
54
+ lifecycles: [],
55
+ behaviors: {},
56
+ };
57
+ }
58
+
59
+ function buildController(modelName: string): any {
60
+ return {
61
+ name: `${modelName}Controller`,
62
+ model: modelName,
63
+ modelReference: modelName,
64
+ cured: { create: {}, retrieve: {}, update: {}, validate: {}, delete: {} },
65
+ };
66
+ }
67
+
68
+ describe('Phase 2 — controller-generator with model.constraints', () => {
69
+ it('emits import for guards module when model has constraints', () => {
70
+ const code = generatePrismaController({
71
+ spec: {} as any,
72
+ factory: {} as any,
73
+ model: buildConstrainedModel(),
74
+ controller: buildController('Vote'),
75
+ models: [buildConstrainedModel()],
76
+ });
77
+ expect(code).toContain(`import { runGuards as runConstraintGuards } from './Vote.guards.js';`);
78
+ });
79
+
80
+ it('does NOT emit guards import for unconstrained models', () => {
81
+ const code = generatePrismaController({
82
+ spec: {} as any,
83
+ factory: {} as any,
84
+ model: buildUnconstrainedModel(),
85
+ controller: buildController('Comment'),
86
+ models: [buildUnconstrainedModel()],
87
+ });
88
+ expect(code).not.toContain('.guards.js');
89
+ expect(code).not.toContain('runConstraintGuards');
90
+ });
91
+
92
+ it('extends validate() signature with actor + widens op union', () => {
93
+ const code = generatePrismaController({
94
+ spec: {} as any,
95
+ factory: {} as any,
96
+ model: buildConstrainedModel(),
97
+ controller: buildController('Vote'),
98
+ models: [buildConstrainedModel()],
99
+ });
100
+ expect(code).toContain('_actor: any = null');
101
+ // Wider union includes 'delete' and the template-literal evolve.<X>.
102
+ expect(code).toContain(`'delete'`);
103
+ expect(code).toContain('`evolve.${string}`');
104
+ });
105
+
106
+ it('emits runConstraintGuards call inside validate() body', () => {
107
+ const code = generatePrismaController({
108
+ spec: {} as any,
109
+ factory: {} as any,
110
+ model: buildConstrainedModel(),
111
+ controller: buildController('Vote'),
112
+ models: [buildConstrainedModel()],
113
+ });
114
+ expect(code).toContain('await runConstraintGuards(_data, _context.operation, _actor, __guardCtx)');
115
+ });
116
+
117
+ // Phase 2 Slice 14 actor wiring — validate() ALWAYS accepts _actor (default
118
+ // null) on EVERY controller, so route handlers can pass `(request as any).user`
119
+ // without per-model knowledge of whether constraints exist. Unconstrained
120
+ // controllers just ignore the param.
121
+ it('emits _actor param on validate() signature for both constrained AND unconstrained', () => {
122
+ const constrained = generatePrismaController({
123
+ spec: {} as any,
124
+ factory: {} as any,
125
+ model: buildConstrainedModel(),
126
+ controller: buildController('Vote'),
127
+ models: [buildConstrainedModel()],
128
+ });
129
+ const unconstrained = generatePrismaController({
130
+ spec: {} as any,
131
+ factory: {} as any,
132
+ model: buildUnconstrainedModel(),
133
+ controller: buildController('Comment'),
134
+ models: [buildUnconstrainedModel()],
135
+ });
136
+ expect(constrained).toContain('_actor: any = null');
137
+ expect(unconstrained).toContain('_actor: any = null');
138
+ // Unconstrained controllers still don't pull in the guards module.
139
+ expect(unconstrained).not.toContain('runConstraintGuards');
140
+ });
141
+
142
+ it('emits delete() with validate call when constrained', () => {
143
+ const code = generatePrismaController({
144
+ spec: {} as any,
145
+ factory: {} as any,
146
+ model: buildConstrainedModel(),
147
+ controller: buildController('Vote'),
148
+ models: [buildConstrainedModel()],
149
+ });
150
+ // Slice 14 — delete() now takes (id, _actor) and threads _actor to validate.
151
+ expect(code).toContain('public async delete(id: string, _actor: any = null)');
152
+ expect(code).toContain(`await this.validate(vote, { operation: 'delete' }, _actor)`);
153
+ });
154
+
155
+ it('emits delete() WITHOUT validate call when unconstrained (backward compat)', () => {
156
+ const code = generatePrismaController({
157
+ spec: {} as any,
158
+ factory: {} as any,
159
+ model: buildUnconstrainedModel(),
160
+ controller: buildController('Comment'),
161
+ models: [buildUnconstrainedModel()],
162
+ });
163
+ // Slice 14 — delete still accepts _actor for uniform route handler calls.
164
+ expect(code).toContain('public async delete(id: string, _actor: any = null)');
165
+ // Original delete just does findUnique + delete + publish — no validate.
166
+ const deleteSection = code.substring(code.indexOf('public async delete(id: string'));
167
+ expect(deleteSection).not.toContain('this.validate(');
168
+ });
169
+
170
+ // Phase 2 carry-over (Update self-from-DB).
171
+ it('update() loads + merges entity before validate when constrained', () => {
172
+ const code = generatePrismaController({
173
+ spec: {} as any,
174
+ factory: {} as any,
175
+ model: buildConstrainedModel(),
176
+ controller: buildController('Vote'),
177
+ models: [buildConstrainedModel()],
178
+ });
179
+ const updateSection = code.substring(code.indexOf('public async update(id: string'));
180
+ expect(updateSection).toContain('__existing');
181
+ expect(updateSection).toContain('findUnique');
182
+ expect(updateSection).toContain('__merged');
183
+ expect(updateSection).toContain(`await this.validate(__merged, { operation: 'update' }, _actor)`);
184
+ });
185
+
186
+ it('update() uses raw input shape when unconstrained (backward compat)', () => {
187
+ const code = generatePrismaController({
188
+ spec: {} as any,
189
+ factory: {} as any,
190
+ model: buildUnconstrainedModel(),
191
+ controller: buildController('Comment'),
192
+ models: [buildUnconstrainedModel()],
193
+ });
194
+ const updateSection = code.substring(code.indexOf('public async update(id: string'));
195
+ expect(updateSection).not.toContain('__existing');
196
+ expect(updateSection).not.toContain('__merged');
197
+ expect(updateSection).toContain(`await this.validate(data, { operation: 'update' }, _actor)`);
198
+ });
199
+
200
+ // Phase 2 Slice 15 — Create-time relation loading. Mirrors the
201
+ // Update self-from-DB pattern but for create: load each belongsTo
202
+ // related entity from input FKs before calling validate, so guards
203
+ // traversing self.<rel>.<field> see the loaded entity.
204
+ it('create() loads belongsTo relations into self when constrained', () => {
205
+ const constrainedWithRels = {
206
+ ...buildConstrainedModel(),
207
+ relationships: [
208
+ { name: 'poll', type: 'belongsTo', target: 'Poll' },
209
+ { name: 'voter', type: 'belongsTo', target: 'User' },
210
+ ],
211
+ };
212
+ const code = generatePrismaController({
213
+ spec: {} as any,
214
+ factory: {} as any,
215
+ model: constrainedWithRels,
216
+ controller: buildController('Vote'),
217
+ models: [constrainedWithRels],
218
+ });
219
+ const createSection = code.substring(code.indexOf('public async create(data: any'));
220
+ expect(createSection).toContain('__loadedRels');
221
+ expect(createSection).toContain('if (data.pollId)');
222
+ expect(createSection).toContain('prisma.poll.findUnique');
223
+ expect(createSection).toContain('if (data.voterId)');
224
+ expect(createSection).toContain('prisma.user.findUnique');
225
+ expect(createSection).toContain('__mergedSelf = { ...data, ...__loadedRels }');
226
+ expect(createSection).toContain(`await this.validate(__mergedSelf, { operation: 'create' }, _actor)`);
227
+ });
228
+
229
+ it('create() skips relation loading when unconstrained (backward compat)', () => {
230
+ const unconstrainedWithRels = {
231
+ ...buildUnconstrainedModel(),
232
+ relationships: [{ name: 'author', type: 'belongsTo', target: 'User' }],
233
+ };
234
+ const code = generatePrismaController({
235
+ spec: {} as any,
236
+ factory: {} as any,
237
+ model: unconstrainedWithRels,
238
+ controller: buildController('Comment'),
239
+ models: [unconstrainedWithRels],
240
+ });
241
+ const createSection = code.substring(code.indexOf('public async create(data: any'));
242
+ expect(createSection).not.toContain('__loadedRels');
243
+ expect(createSection).not.toContain('__mergedSelf');
244
+ expect(createSection).toContain(`await this.validate(data, { operation: 'create' }, _actor)`);
245
+ });
246
+
247
+ it('create() with constraints but no belongsTo skips the load block', () => {
248
+ // hasConstraints but model has no relationships — still emits
249
+ // simple validate(data, ...) since there's nothing to load.
250
+ const code = generatePrismaController({
251
+ spec: {} as any,
252
+ factory: {} as any,
253
+ model: buildConstrainedModel(), // no relationships
254
+ controller: buildController('Vote'),
255
+ models: [buildConstrainedModel()],
256
+ });
257
+ const createSection = code.substring(code.indexOf('public async create(data: any'));
258
+ expect(createSection).not.toContain('__loadedRels');
259
+ expect(createSection).toContain(`await this.validate(data, { operation: 'create' }, _actor)`);
260
+ });
261
+ });