@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.
- 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
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 2 Guards Generator — SHARED across prisma / mongodb-native / postgres-native.
|
|
3
|
+
*
|
|
4
|
+
* Emits `<Model>.guards.ts` containing the transpiled TS guard functions
|
|
5
|
+
* for one model's `constraints[]`, plus a small runtime that the
|
|
6
|
+
* controller's existing `validate()` method calls. The output is
|
|
7
|
+
* ORM-agnostic (operates on `self` + `actor` parameters; doesn't touch
|
|
8
|
+
* the DB), so the same generator file backs all three ORM factories.
|
|
9
|
+
*
|
|
10
|
+
* One file per constrained model. Models with no `constraints` field skip
|
|
11
|
+
* this template entirely (the realize entry point gates the per-model loop
|
|
12
|
+
* on `model.constraints?.length > 0`).
|
|
13
|
+
*
|
|
14
|
+
* Output shape:
|
|
15
|
+
* - Re-exports the transpiled guard fns (one per constraint record)
|
|
16
|
+
* - `MODEL_CONSTRAINTS` — table of `{on: string[], guard: GuardFn, name, source}`
|
|
17
|
+
* - `runGuards(input, op, actor?)` — filters by `on` matcher, invokes each
|
|
18
|
+
* matching guard with (input, actor), returns `Violation[]` (empty = pass)
|
|
19
|
+
*
|
|
20
|
+
* `actor` is `null` by default in Slice 1 (route handlers don't yet thread
|
|
21
|
+
* a user context through to controllers). Constraints referencing `actor.*`
|
|
22
|
+
* paths will fail at runtime when invoked with `null` — the route handler
|
|
23
|
+
* should pass an authenticated user object when one is available.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { TemplateContext, ModelConstraintSpec } from '@specverse/types';
|
|
27
|
+
import { transpilePhase2Guard } from '@specverse/engines/inference';
|
|
28
|
+
|
|
29
|
+
export default function generatePrismaGuards(context: TemplateContext): string {
|
|
30
|
+
const { model } = context;
|
|
31
|
+
|
|
32
|
+
if (!model) {
|
|
33
|
+
throw new Error('Model is required for guards generation');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const constraints: ModelConstraintSpec[] = (model as any).constraints ?? [];
|
|
37
|
+
if (constraints.length === 0) {
|
|
38
|
+
// Defensive: should not be invoked when constraints is empty, but if
|
|
39
|
+
// realize entry gating is bypassed, emit a stub that hides emptiness.
|
|
40
|
+
return generateEmptyGuardsModule(model.name);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const guardFns: string[] = [];
|
|
44
|
+
const constraintsTable: string[] = [];
|
|
45
|
+
|
|
46
|
+
for (const c of constraints) {
|
|
47
|
+
// transpilePhase2Guard wraps body in a synthetic pure def and runs the
|
|
48
|
+
// existing Quint → TS pipeline. The `==` → `===` strict-equality
|
|
49
|
+
// upgrade happens inside applyQuintRewrites.
|
|
50
|
+
const transpiled = transpilePhase2Guard(
|
|
51
|
+
c.requires.name,
|
|
52
|
+
c.requires.params ?? `self: any, actor: any`,
|
|
53
|
+
c.requires.body,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// Phase 2 Slice 15b — subquery rewrite. The Quint transpiler converts
|
|
57
|
+
// `Vote.exists(__v => ...)` to `Vote.some((__v: any) => ...)`, but
|
|
58
|
+
// `Vote` is undefined at runtime (it's a Quint Set reference, not a
|
|
59
|
+
// JS variable). We detect any bare capitalized identifier `.some(...)`
|
|
60
|
+
// pattern in the transpiled body, treat it as a SUBQUERY (query the
|
|
61
|
+
// backing store for entities of that model), and rewrite the guard
|
|
62
|
+
// to an ASYNC function that takes a ctx argument carrying the query
|
|
63
|
+
// helper. Sync (pure self/actor) guards pass through unchanged.
|
|
64
|
+
const rewritten = rewriteSubqueriesAsync(transpiled.typescript, c.requires.name);
|
|
65
|
+
guardFns.push(rewritten);
|
|
66
|
+
|
|
67
|
+
const onArrayLiteral = `[${c.on.map((o) => JSON.stringify(o)).join(', ')}]`;
|
|
68
|
+
const sourceLiteral = JSON.stringify(
|
|
69
|
+
c.requires.source.input ?? '',
|
|
70
|
+
);
|
|
71
|
+
constraintsTable.push(
|
|
72
|
+
` {\n` +
|
|
73
|
+
` on: ${onArrayLiteral},\n` +
|
|
74
|
+
` guard: ${c.requires.name},\n` +
|
|
75
|
+
` name: ${JSON.stringify(c.requires.name)},\n` +
|
|
76
|
+
` source: ${sourceLiteral}\n` +
|
|
77
|
+
` }`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return `/**
|
|
82
|
+
* Auto-generated by SpecVerse Phase 2 (Validate-Centric Constraints).
|
|
83
|
+
*
|
|
84
|
+
* Guard functions for ${model.name}'s declared constraints.
|
|
85
|
+
* DO NOT EDIT — regenerated on every \`spv realize all\`.
|
|
86
|
+
*
|
|
87
|
+
* Slice 1 limitations:
|
|
88
|
+
* - \`self\` is the input payload (not the loaded entity). For Update/Delete/
|
|
89
|
+
* Evolve, paths through \`self\` see only the fields the caller sent.
|
|
90
|
+
* - \`actor\` defaults to null. Constraints using \`actor.*\` paths fail at
|
|
91
|
+
* runtime when called without a user context.
|
|
92
|
+
*/
|
|
93
|
+
|
|
94
|
+
export interface Violation {
|
|
95
|
+
constraint: string;
|
|
96
|
+
scope: string;
|
|
97
|
+
source: string;
|
|
98
|
+
message: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* GuardContext is provided by the controller's validate() method. The
|
|
103
|
+
* \`query\` accessor returns a per-model helper that backs subquery sugars
|
|
104
|
+
* like \`{Actor} has not {verb} on {Target}\` (which the transpiler converts
|
|
105
|
+
* to \`<Model>.some(predicate)\`). Implementations call the ORM's findMany
|
|
106
|
+
* (or the in-memory store for dynamic interpreters).
|
|
107
|
+
*/
|
|
108
|
+
export interface GuardContext {
|
|
109
|
+
query?: (modelName: string) => {
|
|
110
|
+
exists: (predicate: (entity: any) => boolean) => Promise<boolean>;
|
|
111
|
+
} | undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface ConstraintRecord {
|
|
115
|
+
on: string[];
|
|
116
|
+
// Pure self/actor guards stay sync; subquery guards (those that reference
|
|
117
|
+
// bare-Capitalized model names like \`Vote.some(...)\`) are rewritten by
|
|
118
|
+
// the generator into async functions that take a third ctx argument.
|
|
119
|
+
// runGuards awaits the result either way.
|
|
120
|
+
guard: (self: any, actor: any, ctx?: GuardContext) => boolean | Promise<boolean>;
|
|
121
|
+
name: string;
|
|
122
|
+
source: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
${guardFns.join('\n\n')}
|
|
126
|
+
|
|
127
|
+
export const MODEL_CONSTRAINTS: ConstraintRecord[] = [
|
|
128
|
+
${constraintsTable.join(',\n')}
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Match a runtime operation against a constraint's \`on:\` array.
|
|
133
|
+
*
|
|
134
|
+
* Op shapes:
|
|
135
|
+
* - exact string: 'create', 'update', 'delete', 'retrieve'
|
|
136
|
+
* - 'evolve.<transition>': specific lifecycle transition
|
|
137
|
+
*
|
|
138
|
+
* \`on:\` entry shapes:
|
|
139
|
+
* - '*': always matches
|
|
140
|
+
* - 'evolve.*': matches any 'evolve.X' op
|
|
141
|
+
* - else: exact-string equality
|
|
142
|
+
*/
|
|
143
|
+
export function matchesOp(constraintOn: string[], op: string): boolean {
|
|
144
|
+
for (const o of constraintOn) {
|
|
145
|
+
if (o === '*') return true;
|
|
146
|
+
if (o === op) return true;
|
|
147
|
+
if (o === 'evolve.*' && op.startsWith('evolve.')) return true;
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Run all constraints whose \`on:\` matches the requested op. Each guard
|
|
154
|
+
* is invoked with (input, actor). Returns the collected violations.
|
|
155
|
+
*
|
|
156
|
+
* Failure-mode policy (fail-OPEN on exceptions):
|
|
157
|
+
* - Guard returns false → one Violation appended
|
|
158
|
+
* - Guard returns true → skip
|
|
159
|
+
* - Guard throws → console.error + treat as PASS (no violation)
|
|
160
|
+
*
|
|
161
|
+
* Rationale: a throw indicates a guard-internal defect (transpile bug,
|
|
162
|
+
* undefined-path traversal, type mismatch) rather than a real constraint
|
|
163
|
+
* failure. Blocking the mutation would deny legitimate user actions for
|
|
164
|
+
* a bug we already log loudly. Real data-integrity violations still fall
|
|
165
|
+
* back to database constraints + Slice 3 mode-γ preflight retries.
|
|
166
|
+
*/
|
|
167
|
+
export async function runGuards(
|
|
168
|
+
input: any,
|
|
169
|
+
op: string,
|
|
170
|
+
actor: any = null,
|
|
171
|
+
ctx: GuardContext | null = null,
|
|
172
|
+
): Promise<Violation[]> {
|
|
173
|
+
const violations: Violation[] = [];
|
|
174
|
+
for (const c of MODEL_CONSTRAINTS) {
|
|
175
|
+
if (!matchesOp(c.on, op)) continue;
|
|
176
|
+
let passed = true;
|
|
177
|
+
try {
|
|
178
|
+
// await wraps both sync (boolean) and async (Promise<boolean>) guards
|
|
179
|
+
// uniformly. Subquery guards need ctx; sync guards ignore it.
|
|
180
|
+
passed = await c.guard(input, actor, ctx ?? undefined);
|
|
181
|
+
} catch (e: any) {
|
|
182
|
+
// Fail-open: log loudly, do NOT block the mutation. See policy above.
|
|
183
|
+
console.error(
|
|
184
|
+
\`[runGuards] constraint "\${c.name}" (source: \${c.source}) threw during op="\${op}" — treating as PASS:\`,
|
|
185
|
+
e?.stack ?? e?.message ?? e,
|
|
186
|
+
);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (!passed) {
|
|
190
|
+
violations.push({
|
|
191
|
+
constraint: c.name,
|
|
192
|
+
scope: op,
|
|
193
|
+
source: c.source,
|
|
194
|
+
message: \`Constraint "\${c.source}" failed\`,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return violations;
|
|
199
|
+
}
|
|
200
|
+
`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function generateEmptyGuardsModule(modelName: string): string {
|
|
204
|
+
return `/**
|
|
205
|
+
* Auto-generated by SpecVerse Phase 2 — no constraints declared on ${modelName}.
|
|
206
|
+
* Empty stub kept for symmetry; controllers gate their imports on this file.
|
|
207
|
+
*/
|
|
208
|
+
|
|
209
|
+
export interface Violation {
|
|
210
|
+
constraint: string;
|
|
211
|
+
scope: string;
|
|
212
|
+
source: string;
|
|
213
|
+
message: string;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export const MODEL_CONSTRAINTS: any[] = [];
|
|
217
|
+
|
|
218
|
+
export function matchesOp(_constraintOn: string[], _op: string): boolean {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function runGuards(_input: any, _op: string, _actor: any = null, _ctx: any = null): Promise<Violation[]> {
|
|
223
|
+
return [];
|
|
224
|
+
}
|
|
225
|
+
`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Phase 2 Slice 15b — subquery rewrite.
|
|
230
|
+
*
|
|
231
|
+
* The Quint transpiler converts `Vote.exists(__v => predicate)` to
|
|
232
|
+
* `Vote.some((__v: any) => predicate)`. `Vote` is a bare identifier left
|
|
233
|
+
* over from the Quint source — it's a model reference, not a JS variable
|
|
234
|
+
* in scope. We detect these patterns and rewrite the guard to:
|
|
235
|
+
*
|
|
236
|
+
* 1. Become async (returns Promise<boolean>)
|
|
237
|
+
* 2. Accept a third `ctx` argument carrying the per-model query helper
|
|
238
|
+
* 3. Shim each referenced model name to a const that calls ctx.query()
|
|
239
|
+
* 4. Replace `<Model>.some(` with `await __<Model>.exists(`
|
|
240
|
+
*
|
|
241
|
+
* Pure self/actor guards (no bare-Capitalized .some patterns) pass
|
|
242
|
+
* through unchanged — they stay sync, ignore ctx.
|
|
243
|
+
*
|
|
244
|
+
* Fail-open is preserved at the SHIM level: when ctx?.query?.('Vote')
|
|
245
|
+
* returns undefined (no ctx provided, e.g. test path), the shim's
|
|
246
|
+
* `if (!__Vote) return true;` short-circuits to pass. This matches the
|
|
247
|
+
* runGuards-level fail-open contract.
|
|
248
|
+
*/
|
|
249
|
+
function rewriteSubqueriesAsync(transpiled: string, guardName: string): string {
|
|
250
|
+
// Match bare capitalized identifier followed by `.some(` — NOT preceded by
|
|
251
|
+
// a word char or dot, so `x.Vote.some(` and `obj.Vote.some(` don't match.
|
|
252
|
+
// Negative lookbehind requires modern node (✓ — we target 20+).
|
|
253
|
+
const subqueryPattern = /(?<![\w.])([A-Z][A-Za-z0-9_]*)\.some\(/g;
|
|
254
|
+
const matches = Array.from(transpiled.matchAll(subqueryPattern));
|
|
255
|
+
|
|
256
|
+
if (matches.length === 0) {
|
|
257
|
+
return transpiled; // pure guard — no rewrite needed
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Collect unique model names referenced.
|
|
261
|
+
const modelNames = Array.from(new Set(matches.map((m) => m[1])));
|
|
262
|
+
|
|
263
|
+
// Replace `<Model>.some(` with `await __<Model>.exists(` throughout.
|
|
264
|
+
let body = transpiled.replace(subqueryPattern, (_match, modelName) => {
|
|
265
|
+
return `await __${modelName}.exists(`;
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// The function signature was emitted by the transpiler as:
|
|
269
|
+
// `export function <name>(self: <T>, actor: <U>): boolean { ... }`
|
|
270
|
+
// Convert to async + accept ctx + return Promise<boolean>.
|
|
271
|
+
body = body.replace(
|
|
272
|
+
/export\s+function\s+(\w+)\(([^)]*)\)\s*:\s*boolean\s*\{/,
|
|
273
|
+
(_match, fnName, params) => {
|
|
274
|
+
return `export async function ${fnName}(${params}, ctx?: any): Promise<boolean> {`;
|
|
275
|
+
},
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
// Prepend shim consts inside the function body. The function body starts
|
|
279
|
+
// after the opening `{` of the function declaration. We insert immediately
|
|
280
|
+
// after that `{`.
|
|
281
|
+
const shimLines = modelNames.map((m) =>
|
|
282
|
+
` const __${m} = ctx?.query?.(${JSON.stringify(m)});\n` +
|
|
283
|
+
` if (!__${m}) return true; // fail-open: no query ctx, skip subquery`,
|
|
284
|
+
).join('\n');
|
|
285
|
+
|
|
286
|
+
// Inject after the first `{` following the (now async) function signature.
|
|
287
|
+
// Use a marker to find the right position — the transpiled body has a
|
|
288
|
+
// `: Promise<boolean> {` we just emitted.
|
|
289
|
+
body = body.replace(
|
|
290
|
+
/(:\s*Promise<boolean>\s*\{\n)/,
|
|
291
|
+
`$1${shimLines}\n`,
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
void guardName; // reserved for future per-guard logging
|
|
295
|
+
return body;
|
|
296
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 2 — mongodb-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 generateMongoNativeController 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 = generateMongoNativeController({
|
|
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 = generateMongoNativeController({
|
|
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 = generateMongoNativeController({
|
|
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 = generateMongoNativeController({
|
|
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 = generateMongoNativeController({
|
|
120
|
+
spec: {} as any,
|
|
121
|
+
factory: {} as any,
|
|
122
|
+
model: buildConstrainedModel(),
|
|
123
|
+
controller: buildController('Vote'),
|
|
124
|
+
models: [buildConstrainedModel()],
|
|
125
|
+
});
|
|
126
|
+
const unconstrained = generateMongoNativeController({
|
|
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 = generateMongoNativeController({
|
|
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 = generateMongoNativeController({
|
|
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 = generateMongoNativeController({
|
|
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('findOne');
|
|
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 = generateMongoNativeController({
|
|
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
|
+
});
|