@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.
- 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/inference/ui-contracts/index.d.ts.map +1 -1
- package/dist/inference/ui-contracts/index.js +2 -0
- package/dist/inference/ui-contracts/index.js.map +1 -1
- package/dist/inference/ui-contracts/rules/_shared.d.ts +8 -0
- package/dist/inference/ui-contracts/rules/_shared.d.ts.map +1 -1
- package/dist/inference/ui-contracts/rules/_shared.js +20 -0
- package/dist/inference/ui-contracts/rules/_shared.js.map +1 -1
- package/dist/inference/ui-contracts/rules/belongsto-shows-name-in-list.d.ts +29 -0
- package/dist/inference/ui-contracts/rules/belongsto-shows-name-in-list.d.ts.map +1 -0
- package/dist/inference/ui-contracts/rules/belongsto-shows-name-in-list.js +88 -0
- package/dist/inference/ui-contracts/rules/belongsto-shows-name-in-list.js.map +1 -0
- 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
|
@@ -49,12 +49,17 @@ export default function generatePgNativeController(context: TemplateContext): st
|
|
|
49
49
|
|
|
50
50
|
const customActions = generateCustomActions(controller, modelRegistry);
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
const
|
|
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
|
-
|
|
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:
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|