@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
@@ -48,12 +48,20 @@ export default function generateMongoNativeController(context: TemplateContext):
48
48
 
49
49
  const customActions = generateCustomActions(controller, modelRegistry);
50
50
 
51
- const validate = generateValidateMethod(model, modelName);
52
- const create = curedOps.create ? generateCreateMethod(model, modelName, modelVar, collection) : '';
51
+ // Phase 2 the guards module only exists for models with declared
52
+ // constraints, so the import + validate() call site are gated on this
53
+ // flag. Computed up-front so generateValidateMethod / generateEvolveMethod
54
+ // / generateDeleteMethod can emit runConstraintGuards.
55
+ const hasConstraints =
56
+ Array.isArray((model as any).constraints) &&
57
+ (model as any).constraints.length > 0;
58
+
59
+ const validate = generateValidateMethod(model, modelName, hasConstraints);
60
+ const create = curedOps.create ? generateCreateMethod(model, modelName, modelVar, collection, hasConstraints) : '';
53
61
  const retrieve = curedOps.retrieve ? generateRetrieveMethod(modelName, modelVar, collection) : '';
54
- const update = curedOps.update ? generateUpdateMethod(modelName, modelVar, collection) : '';
55
- const evolve = curedOps.evolve ? generateEvolveMethod(model, modelName, modelVar, collection) : '';
56
- const del = curedOps.delete ? generateDeleteMethod(modelName, modelVar, collection) : '';
62
+ const update = curedOps.update ? generateUpdateMethod(modelName, modelVar, collection, hasConstraints) : '';
63
+ const evolve = curedOps.evolve ? generateEvolveMethod(model, modelName, modelVar, collection, hasConstraints) : '';
64
+ const del = curedOps.delete ? generateDeleteMethod(modelName, modelVar, collection, hasConstraints) : '';
57
65
 
58
66
  const hasEventPublishing =
59
67
  curedOps.create || curedOps.update || curedOps.evolve || curedOps.delete;
@@ -67,6 +75,7 @@ import { ObjectId, type Filter, type Document } from 'mongodb';
67
75
  import { getCollection } from '../db/mongoClient.js';
68
76
  ${hasEventPublishing || customActions.needsAiBehaviors ? `import { eventBus } from '../events/eventBus.js';` : ''}
69
77
  ${customActions.needsAiBehaviors ? `import * as aiBehaviors from '../behaviors/${controllerName}.ai.js';` : ''}
78
+ ${hasConstraints ? `import { runGuards as runConstraintGuards } from './${modelName}.guards.js';` : ''}
70
79
 
71
80
  const COLLECTION_NAME = '${collection}';
72
81
 
@@ -107,17 +116,48 @@ function collectionName(model: any): string {
107
116
  return model.name.toLowerCase() + 's';
108
117
  }
109
118
 
110
- function generateValidateMethod(model: any, modelName: string): string {
119
+ /**
120
+ * Phase 2 — when the model has declared constraints, validate() ALSO
121
+ * calls runConstraintGuards from the per-model .guards.ts and appends
122
+ * any constraint violation messages to errors[]. Op union widens to
123
+ * include 'delete' + evolve.<transition>.
124
+ */
125
+ function generateValidateMethod(model: any, modelName: string, hasConstraints: boolean): string {
126
+ const opTypeUnion = hasConstraints
127
+ ? `'create' | 'update' | 'evolve' | 'delete' | \`evolve.\${string}\``
128
+ : `'create' | 'update' | 'evolve'`;
129
+
130
+ const constraintsCheck = hasConstraints
131
+ ? `
132
+ // Phase 2 — run model.constraints[] guards matching this operation.
133
+ // Slice 15b — ctx carries a per-model query helper for subquery sugars.
134
+ const __guardCtx = {
135
+ query: (modelName: string) => ({
136
+ exists: async (predicate: (e: any) => boolean) => {
137
+ const __col = await getCollection(modelName.toLowerCase() + 's');
138
+ const all = await __col.find({}).toArray();
139
+ return all.some(predicate);
140
+ },
141
+ }),
142
+ };
143
+ const constraintViolations = await runConstraintGuards(_data, _context.operation, _actor, __guardCtx);
144
+ for (const v of constraintViolations) errors.push(v.message);`
145
+ : '';
146
+
147
+ // Phase 2 actor wiring + Slice 15b — validate is async (awaits subquery
148
+ // execution in guard ctx); callers must await this.validate(...).
149
+
111
150
  return `
112
151
  /**
113
- * Validate ${modelName} data — runs before create / update / evolve.
152
+ * Validate ${modelName} data — runs before create / update / evolve / delete.
114
153
  */
115
- public validate(
154
+ public async validate(
116
155
  _data: any,
117
- _context: { operation: 'create' | 'update' | 'evolve' }
118
- ): { valid: boolean; errors: string[] } {
156
+ _context: { operation: ${opTypeUnion} },
157
+ _actor: any = null
158
+ ): Promise<{ valid: boolean; errors: string[] }> {
119
159
  const errors: string[] = [];
120
- ${generateValidationLogic(model)}
160
+ ${generateValidationLogic(model)}${constraintsCheck}
121
161
  return { valid: errors.length === 0, errors };
122
162
  }
123
163
  `;
@@ -148,13 +188,25 @@ function generateValidationLogic(model: any): string {
148
188
  return out.join('\n') || ' // No validation rules defined';
149
189
  }
150
190
 
151
- function generateCreateMethod(model: any, modelName: string, modelVar: string, collection: string): string {
191
+ function generateCreateMethod(model: any, modelName: string, modelVar: string, collection: string, hasConstraints: boolean = false): string {
192
+ const belongsToLoad = hasConstraints ? generateMongoBelongsToLoad(model) : '';
193
+ const validateSelf = belongsToLoad ? '__mergedSelf' : 'data';
194
+
152
195
  return `
153
196
  /**
154
197
  * Create a new ${modelName}.
155
198
  */
156
- public async create(data: any): Promise<any> {
157
- const validation = this.validate(data, { operation: 'create' });
199
+ public async create(data: any, _actor: any = null): Promise<any> {
200
+ ${belongsToLoad ? `
201
+ // Phase 2 Slice 15 — Create-time relation loading. Mirrors prisma's
202
+ // pattern: for each belongsTo FK in input, load the related doc and
203
+ // merge into self so constraint guards traversing self.<rel> see
204
+ // the full related entity, not just the FK id.
205
+ const __loadedRels: Record<string, any> = {};
206
+ ${belongsToLoad}
207
+ const __mergedSelf = { ...data, ...__loadedRels };
208
+ ` : ''}
209
+ const validation = await this.validate(${validateSelf}, { operation: 'create' }, _actor);
158
210
  if (!validation.valid) throw new Error(\`Validation failed: \${validation.errors.join(', ')}\`);
159
211
 
160
212
  const collection = await getCollection(COLLECTION_NAME);
@@ -167,6 +219,28 @@ function generateCreateMethod(model: any, modelName: string, modelVar: string, c
167
219
  `;
168
220
  }
169
221
 
222
+ function generateMongoBelongsToLoad(model: any): string {
223
+ const rels = Array.isArray(model.relationships)
224
+ ? model.relationships
225
+ : Object.values(model.relationships || {});
226
+ const belongsToRels = (rels as any[]).filter(r => r.type === 'belongsTo');
227
+ if (belongsToRels.length === 0) return '';
228
+
229
+ return belongsToRels.map((rel: any) => {
230
+ const relName = rel.name;
231
+ const targetName = rel.target;
232
+ const fkField = `${relName}Id`;
233
+ // Mongo collections are typically named after the model in some casing.
234
+ // Use the same convention the rest of the generator uses (COLLECTION_NAME
235
+ // is per-controller). For cross-model loads, use the target's plural-ish
236
+ // collection name. Since collection naming varies, we use a runtime helper.
237
+ return `if (data.${fkField}) {
238
+ const __col_${relName} = await getCollection('${targetName.toLowerCase()}s');
239
+ __loadedRels.${relName} = await __col_${relName}.findOne(byId(data.${fkField}));
240
+ }`;
241
+ }).join('\n ');
242
+ }
243
+
170
244
  function generateRetrieveMethod(modelName: string, modelVar: string, collection: string): string {
171
245
  return `
172
246
  /**
@@ -190,13 +264,23 @@ function generateRetrieveMethod(modelName: string, modelVar: string, collection:
190
264
  `;
191
265
  }
192
266
 
193
- function generateUpdateMethod(modelName: string, modelVar: string, collection: string): string {
267
+ function generateUpdateMethod(modelName: string, modelVar: string, collection: string, hasConstraints: boolean = false): string {
194
268
  return `
195
269
  /**
196
270
  * Update ${modelName}.
197
271
  */
198
- public async update(id: string, data: any): Promise<any> {
199
- const validation = this.validate(data, { operation: 'update' });
272
+ public async update(id: string, data: any, _actor: any = null): Promise<any> {
273
+ const collection = await getCollection(COLLECTION_NAME);
274
+ ${hasConstraints ? `
275
+ // Phase 2 — Update self-from-DB. Load + merge before validate so
276
+ // update-time constraints see the full entity, not just the partial input.
277
+ const __existing = await collection.findOne(byId(id));
278
+ if (!__existing) throw new Error('${modelName} not found');
279
+ const __merged = { ...__existing, ...data };
280
+ const validation = await this.validate(__merged, { operation: 'update' }, _actor);
281
+ ` : `
282
+ const validation = await this.validate(data, { operation: 'update' }, _actor);
283
+ `}
200
284
  if (!validation.valid) throw new Error(\`Validation failed: \${validation.errors.join(', ')}\`);
201
285
 
202
286
  // Strip nested objects + id — only scalar fields are written.
@@ -208,7 +292,6 @@ function generateUpdateMethod(modelName: string, modelVar: string, collection: s
208
292
  updateData[key] = value;
209
293
  }
210
294
 
211
- const collection = await getCollection(COLLECTION_NAME);
212
295
  await collection.updateOne(byId(id), { $set: updateData });
213
296
  const ${modelVar} = await collection.findOne(byId(id));
214
297
  if (!${modelVar}) throw new Error('${modelName} not found after update');
@@ -219,7 +302,7 @@ function generateUpdateMethod(modelName: string, modelVar: string, collection: s
219
302
  `;
220
303
  }
221
304
 
222
- function generateEvolveMethod(model: any, modelName: string, modelVar: string, collection: string): string {
305
+ function generateEvolveMethod(model: any, modelName: string, modelVar: string, collection: string, hasConstraints: boolean = false): string {
223
306
  const lifecycles = Array.isArray(model.lifecycles)
224
307
  ? model.lifecycles
225
308
  : (model.lifecycles ? Object.entries(model.lifecycles).map(([name, lc]: [string, any]) => ({ name, ...lc })) : []);
@@ -235,12 +318,26 @@ function generateEvolveMethod(model: any, modelName: string, modelVar: string, c
235
318
  ...Object.values(validTransitions).flat(),
236
319
  ]));
237
320
 
321
+ // Phase 2 — reverse map (currentState -> targetState -> actionName) so
322
+ // evolve() can pass the correct `evolve.<actionName>` op to validate.
323
+ const evolveOpsMap: Record<string, Record<string, string>> = {};
324
+ if (lifecycle?.transitions) {
325
+ for (const [actionName, t] of Object.entries(lifecycle.transitions as Record<string, any>)) {
326
+ const from = (t as any).from;
327
+ const to = (t as any).to;
328
+ if (typeof from === 'string' && typeof to === 'string') {
329
+ evolveOpsMap[from] = evolveOpsMap[from] ?? {};
330
+ evolveOpsMap[from][to] = actionName;
331
+ }
332
+ }
333
+ }
334
+
238
335
  return `
239
336
  /**
240
337
  * Evolve ${modelName} through lifecycle "${lifecycleName}"
241
338
  * States: ${states.join(' → ') || '(none declared)'}
242
339
  */
243
- public async evolve(id: string, data: any): Promise<any> {
340
+ public async evolve(id: string, data: any, _actor: any = null): Promise<any> {
244
341
  const collection = await getCollection(COLLECTION_NAME);
245
342
  const current = await collection.findOne(byId(id));
246
343
  if (!current) throw new Error('${modelName} not found');
@@ -257,7 +354,22 @@ function generateEvolveMethod(model: any, modelName: string, modelVar: string, c
257
354
  throw new Error(\`Invalid transition: \${currentState} → \${targetState}. Allowed: \${allowed.join(', ') || 'none'}\`);
258
355
  }
259
356
  ` : ''}
260
-
357
+ ${hasConstraints ? `
358
+ // Phase 2 — resolve the transition's action name so constraints
359
+ // scoped to \`on: 'evolve.<action>'\` match correctly. EVOLVE_OPS is
360
+ // baked at codegen from the lifecycle definition.
361
+ const EVOLVE_OPS: Record<string, Record<string, string>> = ${JSON.stringify(evolveOpsMap)};
362
+ const currentStateForOp = (current as any)[targetLifecycle];
363
+ const actionName = EVOLVE_OPS[currentStateForOp]?.[targetState] ?? targetState;
364
+ const evolveValidation = await this.validate(
365
+ { ...data, ...current, [targetLifecycle]: targetState },
366
+ { operation: \`evolve.\${actionName}\` as any },
367
+ _actor
368
+ );
369
+ if (!evolveValidation.valid) {
370
+ throw new Error(\`Validation failed: \${evolveValidation.errors.join(', ')}\`);
371
+ }
372
+ ` : ''}
261
373
  await collection.updateOne(byId(id), { $set: { [targetLifecycle]: targetState } });
262
374
  const ${modelVar} = await collection.findOne(byId(id));
263
375
  if (!${modelVar}) throw new Error('${modelName} not found after evolve');
@@ -268,14 +380,23 @@ function generateEvolveMethod(model: any, modelName: string, modelVar: string, c
268
380
  `;
269
381
  }
270
382
 
271
- function generateDeleteMethod(modelName: string, modelVar: string, collection: string): string {
383
+ function generateDeleteMethod(modelName: string, modelVar: string, collection: string, hasConstraints: boolean = false): string {
272
384
  return `
273
385
  /**
274
386
  * Delete ${modelName}.
275
387
  */
276
- public async delete(id: string): Promise<void> {
388
+ public async delete(id: string, _actor: any = null): Promise<void> {
277
389
  const collection = await getCollection(COLLECTION_NAME);
278
390
  const ${modelVar} = await collection.findOne(byId(id));
391
+ ${hasConstraints ? `
392
+ // Phase 2 — run delete-scoped constraint guards against the loaded record.
393
+ if (${modelVar}) {
394
+ const deleteValidation = await this.validate(${modelVar}, { operation: 'delete' }, _actor);
395
+ if (!deleteValidation.valid) {
396
+ throw new Error(\`Validation failed: \${deleteValidation.errors.join(', ')}\`);
397
+ }
398
+ }
399
+ ` : ''}
279
400
  await collection.deleteOne(byId(id));
280
401
  if (${modelVar}) {
281
402
  await eventBus.publish('${modelName}Deleted', { ...${modelVar}, timestamp: new Date().toISOString() } as any);
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Phase 2 — postgres-native controller-generator emits guards import +
3
+ * extended validate() body when the model has declared constraints
4
+ * (mirrors the prisma controller-with-constraints test).
5
+ *
6
+ * Smoke-shape test: asserts STRINGS in the generated controller source.
7
+ */
8
+
9
+ import { describe, it, expect } from 'vitest';
10
+ import generatePgNativeController from '../controller-generator.js';
11
+ import type { ModelConstraintSpec } from '@specverse/types';
12
+
13
+ function buildConstrainedModel(): any {
14
+ const expanded: ModelConstraintSpec = {
15
+ on: ['create'],
16
+ requires: {
17
+ type: 'guard',
18
+ name: 'Vote_create_requires',
19
+ body: 'self.poll.votingStatus == "open"',
20
+ params: 'self: Vote, actor: User',
21
+ source: {
22
+ convention: 'compound',
23
+ entity: 'constraints',
24
+ input: 'self.poll.votingStatus == "open"',
25
+ },
26
+ },
27
+ source: {
28
+ authorOn: 'create',
29
+ authorRequires: 'self.poll.votingStatus == "open"',
30
+ },
31
+ };
32
+ return {
33
+ name: 'Vote',
34
+ attributes: [
35
+ { name: 'id', type: 'UUID', required: true, unique: true, category: 'metadata', auto: 'uuid4' },
36
+ { name: 'choice', type: 'String', required: true, unique: false, category: 'business' },
37
+ ],
38
+ relationships: [],
39
+ lifecycles: [],
40
+ behaviors: {},
41
+ constraints: [expanded],
42
+ };
43
+ }
44
+
45
+ function buildUnconstrainedModel(): any {
46
+ return {
47
+ name: 'Comment',
48
+ attributes: [
49
+ { name: 'id', type: 'UUID', required: true, unique: true, category: 'metadata', auto: 'uuid4' },
50
+ { name: 'text', type: 'String', required: true, unique: false, category: 'business' },
51
+ ],
52
+ relationships: [],
53
+ lifecycles: [],
54
+ behaviors: {},
55
+ };
56
+ }
57
+
58
+ function buildController(modelName: string): any {
59
+ return {
60
+ name: `${modelName}Controller`,
61
+ model: modelName,
62
+ modelReference: modelName,
63
+ cured: { create: {}, retrieve: {}, update: {}, validate: {}, delete: {} },
64
+ };
65
+ }
66
+
67
+ describe('Phase 2 — controller-generator with model.constraints', () => {
68
+ it('emits import for guards module when model has constraints', () => {
69
+ const code = generatePgNativeController({
70
+ spec: {} as any,
71
+ factory: {} as any,
72
+ model: buildConstrainedModel(),
73
+ controller: buildController('Vote'),
74
+ models: [buildConstrainedModel()],
75
+ });
76
+ expect(code).toContain(`import { runGuards as runConstraintGuards } from './Vote.guards.js';`);
77
+ });
78
+
79
+ it('does NOT emit guards import for unconstrained models', () => {
80
+ const code = generatePgNativeController({
81
+ spec: {} as any,
82
+ factory: {} as any,
83
+ model: buildUnconstrainedModel(),
84
+ controller: buildController('Comment'),
85
+ models: [buildUnconstrainedModel()],
86
+ });
87
+ expect(code).not.toContain('.guards.js');
88
+ expect(code).not.toContain('runConstraintGuards');
89
+ });
90
+
91
+ it('extends validate() signature with actor + widens op union', () => {
92
+ const code = generatePgNativeController({
93
+ spec: {} as any,
94
+ factory: {} as any,
95
+ model: buildConstrainedModel(),
96
+ controller: buildController('Vote'),
97
+ models: [buildConstrainedModel()],
98
+ });
99
+ expect(code).toContain('_actor: any = null');
100
+ // Wider union includes 'delete' and the template-literal evolve.<X>.
101
+ expect(code).toContain(`'delete'`);
102
+ expect(code).toContain('`evolve.${string}`');
103
+ });
104
+
105
+ it('emits runConstraintGuards call inside validate() body', () => {
106
+ const code = generatePgNativeController({
107
+ spec: {} as any,
108
+ factory: {} as any,
109
+ model: buildConstrainedModel(),
110
+ controller: buildController('Vote'),
111
+ models: [buildConstrainedModel()],
112
+ });
113
+ expect(code).toContain('await runConstraintGuards(_data, _context.operation, _actor, __guardCtx)');
114
+ });
115
+
116
+ // Slice 14 actor wiring — validate() ALWAYS accepts _actor (default null)
117
+ // on EVERY controller for uniform route-handler call sites.
118
+ it('emits _actor param on validate() signature for both constrained AND unconstrained', () => {
119
+ const constrained = generatePgNativeController({
120
+ spec: {} as any,
121
+ factory: {} as any,
122
+ model: buildConstrainedModel(),
123
+ controller: buildController('Vote'),
124
+ models: [buildConstrainedModel()],
125
+ });
126
+ const unconstrained = generatePgNativeController({
127
+ spec: {} as any,
128
+ factory: {} as any,
129
+ model: buildUnconstrainedModel(),
130
+ controller: buildController('Comment'),
131
+ models: [buildUnconstrainedModel()],
132
+ });
133
+ expect(constrained).toContain('_actor: any = null');
134
+ expect(unconstrained).toContain('_actor: any = null');
135
+ expect(unconstrained).not.toContain('runConstraintGuards');
136
+ });
137
+
138
+ it('emits delete() with validate call when constrained', () => {
139
+ const code = generatePgNativeController({
140
+ spec: {} as any,
141
+ factory: {} as any,
142
+ model: buildConstrainedModel(),
143
+ controller: buildController('Vote'),
144
+ models: [buildConstrainedModel()],
145
+ });
146
+ expect(code).toContain('public async delete(id: string, _actor: any = null)');
147
+ expect(code).toContain(`await this.validate(vote, { operation: 'delete' }, _actor)`);
148
+ });
149
+
150
+ it('emits delete() WITHOUT validate call when unconstrained (backward compat)', () => {
151
+ const code = generatePgNativeController({
152
+ spec: {} as any,
153
+ factory: {} as any,
154
+ model: buildUnconstrainedModel(),
155
+ controller: buildController('Comment'),
156
+ models: [buildUnconstrainedModel()],
157
+ });
158
+ expect(code).toContain('public async delete(id: string, _actor: any = null)');
159
+ const deleteSection = code.substring(code.indexOf('public async delete(id: string'));
160
+ expect(deleteSection).not.toContain('this.validate(');
161
+ });
162
+
163
+ // Phase 2 carry-over (Update self-from-DB).
164
+ it('update() loads + merges entity before validate when constrained', () => {
165
+ const code = generatePgNativeController({
166
+ spec: {} as any,
167
+ factory: {} as any,
168
+ model: buildConstrainedModel(),
169
+ controller: buildController('Vote'),
170
+ models: [buildConstrainedModel()],
171
+ });
172
+ const updateSection = code.substring(code.indexOf('public async update(id: string'));
173
+ expect(updateSection).toContain('__existing');
174
+ expect(updateSection).toContain('findOneByField');
175
+ expect(updateSection).toContain('__merged');
176
+ expect(updateSection).toContain(`await this.validate(__merged, { operation: 'update' }, _actor)`);
177
+ });
178
+
179
+ it('update() uses raw input shape when unconstrained (backward compat)', () => {
180
+ const code = generatePgNativeController({
181
+ spec: {} as any,
182
+ factory: {} as any,
183
+ model: buildUnconstrainedModel(),
184
+ controller: buildController('Comment'),
185
+ models: [buildUnconstrainedModel()],
186
+ });
187
+ const updateSection = code.substring(code.indexOf('public async update(id: string'));
188
+ expect(updateSection).not.toContain('__existing');
189
+ expect(updateSection).not.toContain('__merged');
190
+ expect(updateSection).toContain(`await this.validate(data, { operation: 'update' }, _actor)`);
191
+ });
192
+ });