@specverse/engines 6.65.0 → 6.75.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai/providers/claude-cli.d.ts +14 -0
- package/dist/ai/providers/claude-cli.d.ts.map +1 -1
- package/dist/ai/providers/claude-cli.js +167 -17
- package/dist/ai/providers/claude-cli.js.map +1 -1
- package/dist/inference/index.d.ts +1 -1
- package/dist/inference/index.d.ts.map +1 -1
- package/dist/inference/index.js +1 -1
- package/dist/inference/index.js.map +1 -1
- package/dist/inference/quint-transpiler.d.ts +18 -0
- package/dist/inference/quint-transpiler.d.ts.map +1 -1
- package/dist/inference/quint-transpiler.js +32 -0
- package/dist/inference/quint-transpiler.js.map +1 -1
- package/dist/libs/instance-factories/controllers/templates/fastify/routes-generator.js +14 -5
- package/dist/libs/instance-factories/services/mongodb-native-services.yaml +10 -0
- package/dist/libs/instance-factories/services/postgres-native-services.yaml +10 -0
- package/dist/libs/instance-factories/services/prisma-services.yaml +10 -0
- package/dist/libs/instance-factories/services/templates/_shared/guards-generator.js +209 -0
- package/dist/libs/instance-factories/services/templates/mongodb-native/controller-generator.js +110 -23
- package/dist/libs/instance-factories/services/templates/postgres-native/controller-generator.js +104 -22
- package/dist/libs/instance-factories/services/templates/prisma/controller-generator.js +133 -23
- package/dist/libs/instance-factories/services/templates/prisma/guards-generator.js +151 -0
- package/dist/parser/convention-processor.d.ts +44 -1
- package/dist/parser/convention-processor.d.ts.map +1 -1
- package/dist/parser/convention-processor.js +175 -1
- package/dist/parser/convention-processor.js.map +1 -1
- package/dist/parser/types/ast.d.ts +1 -1
- package/dist/parser/types/ast.d.ts.map +1 -1
- package/dist/parser/unified-parser.d.ts.map +1 -1
- package/dist/parser/unified-parser.js +25 -2
- package/dist/parser/unified-parser.js.map +1 -1
- package/dist/realize/index.d.ts.map +1 -1
- package/dist/realize/index.js +17 -0
- package/dist/realize/index.js.map +1 -1
- package/libs/instance-factories/controllers/templates/fastify/__tests__/actor-wiring.test.ts +80 -0
- package/libs/instance-factories/controllers/templates/fastify/routes-generator.ts +14 -5
- package/libs/instance-factories/services/mongodb-native-services.yaml +10 -0
- package/libs/instance-factories/services/postgres-native-services.yaml +10 -0
- package/libs/instance-factories/services/prisma-services.yaml +10 -0
- package/libs/instance-factories/services/templates/_shared/guards-generator.ts +296 -0
- package/libs/instance-factories/services/templates/mongodb-native/__tests__/controller-with-constraints.test.ts +192 -0
- package/libs/instance-factories/services/templates/mongodb-native/controller-generator.ts +144 -23
- package/libs/instance-factories/services/templates/postgres-native/__tests__/controller-with-constraints.test.ts +192 -0
- package/libs/instance-factories/services/templates/postgres-native/controller-generator.ts +130 -22
- package/libs/instance-factories/services/templates/prisma/__tests__/controller-with-constraints.test.ts +261 -0
- package/libs/instance-factories/services/templates/prisma/controller-generator.ts +186 -22
- package/package.json +1 -1
|
@@ -48,12 +48,20 @@ export default function generateMongoNativeController(context: TemplateContext):
|
|
|
48
48
|
|
|
49
49
|
const customActions = generateCustomActions(controller, modelRegistry);
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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:
|
|
118
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
});
|