@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
|
@@ -186,7 +186,11 @@ function generateHandlerBody(operation, modelName, handlerName, isModelControlle
|
|
|
186
186
|
switch (mappedOperation) {
|
|
187
187
|
case "create":
|
|
188
188
|
return `try {
|
|
189
|
-
|
|
189
|
+
// Phase 2 actor wiring \u2014 pulled from request.user (decorated by auth
|
|
190
|
+
// middleware like @fastify/jwt). null when no auth wired; constraint
|
|
191
|
+
// guards that reference actor.* paths fail-open (logged, treated as pass).
|
|
192
|
+
const _actor = (request as any).user ?? null;
|
|
193
|
+
const ${lowerModel} = await handler.create(request.body as any, _actor);
|
|
190
194
|
return reply.status(201).send(${lowerModel});
|
|
191
195
|
} catch (error) {
|
|
192
196
|
return reply.status(400).send({
|
|
@@ -214,7 +218,8 @@ function generateHandlerBody(operation, modelName, handlerName, isModelControlle
|
|
|
214
218
|
case "evolve":
|
|
215
219
|
return `try {
|
|
216
220
|
const { id } = request.params as { id: string };
|
|
217
|
-
const
|
|
221
|
+
const _actor = (request as any).user ?? null;
|
|
222
|
+
const ${lowerModel} = await handler.${operation}(id, request.body as any, _actor);
|
|
218
223
|
return reply.send(${lowerModel});
|
|
219
224
|
} catch (error) {
|
|
220
225
|
return reply.status(400).send({
|
|
@@ -225,7 +230,8 @@ function generateHandlerBody(operation, modelName, handlerName, isModelControlle
|
|
|
225
230
|
case "delete":
|
|
226
231
|
return `try {
|
|
227
232
|
const { id } = request.params as { id: string };
|
|
228
|
-
|
|
233
|
+
const _actor = (request as any).user ?? null;
|
|
234
|
+
await handler.delete(id, _actor);
|
|
229
235
|
return reply.status(204).send();
|
|
230
236
|
} catch (error) {
|
|
231
237
|
// Prisma's P2003 is the foreign-key-constraint-violated error. When
|
|
@@ -258,7 +264,9 @@ function generateHandlerBody(operation, modelName, handlerName, isModelControlle
|
|
|
258
264
|
case "validate":
|
|
259
265
|
return `try {
|
|
260
266
|
const { data, operation: op } = request.body as { data: any; operation: string };
|
|
261
|
-
const
|
|
267
|
+
const _actor = (request as any).user ?? null;
|
|
268
|
+
// Slice 15b \u2014 validate() is async (awaits guard ctx subqueries).
|
|
269
|
+
const result = await handler.validate(data, { operation: op }, _actor);
|
|
262
270
|
return reply.send(result);
|
|
263
271
|
} catch (error) {
|
|
264
272
|
return reply.status(400).send({
|
|
@@ -298,8 +306,9 @@ ${validations.join("\n")}
|
|
|
298
306
|
const params = (request.params || {}) as Record<string, any>;
|
|
299
307
|
const body = (request.body || {}) as Record<string, any>;
|
|
300
308
|
const args = { ...params, ...body };
|
|
309
|
+
const _actor = (request as any).user ?? null;
|
|
301
310
|
${validationBlock}
|
|
302
|
-
const result = await handler.${operation}(args);
|
|
311
|
+
const result = await handler.${operation}(args, _actor);
|
|
303
312
|
return reply.send(result || { success: true });
|
|
304
313
|
} catch (error) {
|
|
305
314
|
const msg = error instanceof Error ? error.message : String(error);
|
|
@@ -59,6 +59,16 @@ codeTemplates:
|
|
|
59
59
|
generator: "libs/instance-factories/services/templates/mongodb-native/controller-generator.ts"
|
|
60
60
|
outputPattern: "{backendDir}/src/controllers/{model}Controller.ts"
|
|
61
61
|
|
|
62
|
+
# Phase 2 (Validate-Centric Constraints — 2026-05-18)
|
|
63
|
+
# Per-model guards module containing transpiled TS guard functions.
|
|
64
|
+
# Shared generator across all three ORMs (ORM-agnostic — operates on
|
|
65
|
+
# `self` + `actor`, doesn't touch the DB). Realize entry only invokes
|
|
66
|
+
# this template when the model has `constraints[]` declared.
|
|
67
|
+
guards:
|
|
68
|
+
engine: typescript
|
|
69
|
+
generator: "libs/instance-factories/services/templates/_shared/guards-generator.ts"
|
|
70
|
+
outputPattern: "{backendDir}/src/controllers/{model}.guards.ts"
|
|
71
|
+
|
|
62
72
|
services:
|
|
63
73
|
engine: typescript
|
|
64
74
|
generator: "libs/instance-factories/services/templates/mongodb-native/service-generator.ts"
|
|
@@ -65,6 +65,16 @@ codeTemplates:
|
|
|
65
65
|
generator: "libs/instance-factories/services/templates/postgres-native/controller-generator.ts"
|
|
66
66
|
outputPattern: "{backendDir}/src/controllers/{model}Controller.ts"
|
|
67
67
|
|
|
68
|
+
# Phase 2 (Validate-Centric Constraints — 2026-05-18)
|
|
69
|
+
# Per-model guards module containing transpiled TS guard functions.
|
|
70
|
+
# Shared generator across all three ORMs (ORM-agnostic — operates on
|
|
71
|
+
# `self` + `actor`, doesn't touch the DB). Realize entry only invokes
|
|
72
|
+
# this template when the model has `constraints[]` declared.
|
|
73
|
+
guards:
|
|
74
|
+
engine: typescript
|
|
75
|
+
generator: "libs/instance-factories/services/templates/_shared/guards-generator.ts"
|
|
76
|
+
outputPattern: "{backendDir}/src/controllers/{model}.guards.ts"
|
|
77
|
+
|
|
68
78
|
services:
|
|
69
79
|
engine: typescript
|
|
70
80
|
generator: "libs/instance-factories/services/templates/postgres-native/service-generator.ts"
|
|
@@ -42,6 +42,16 @@ codeTemplates:
|
|
|
42
42
|
generator: "libs/instance-factories/services/templates/prisma/controller-generator.ts"
|
|
43
43
|
outputPattern: "{backendDir}/src/controllers/{model}Controller.ts"
|
|
44
44
|
|
|
45
|
+
# Phase 2 (Validate-Centric Constraints — 2026-05-18)
|
|
46
|
+
# Per-model guards module containing transpiled TS guard functions for
|
|
47
|
+
# `model.constraints[]`. Realize entry only invokes this template when
|
|
48
|
+
# the model has constraints declared. The generator is ORM-agnostic and
|
|
49
|
+
# shared across prisma / mongodb-native / postgres-native.
|
|
50
|
+
guards:
|
|
51
|
+
engine: typescript
|
|
52
|
+
generator: "libs/instance-factories/services/templates/_shared/guards-generator.ts"
|
|
53
|
+
outputPattern: "{backendDir}/src/controllers/{model}.guards.ts"
|
|
54
|
+
|
|
45
55
|
services:
|
|
46
56
|
engine: typescript
|
|
47
57
|
generator: "libs/instance-factories/services/templates/prisma/service-generator.ts"
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { transpilePhase2Guard } from "@specverse/engines/inference";
|
|
2
|
+
function generatePrismaGuards(context) {
|
|
3
|
+
const { model } = context;
|
|
4
|
+
if (!model) {
|
|
5
|
+
throw new Error("Model is required for guards generation");
|
|
6
|
+
}
|
|
7
|
+
const constraints = model.constraints ?? [];
|
|
8
|
+
if (constraints.length === 0) {
|
|
9
|
+
return generateEmptyGuardsModule(model.name);
|
|
10
|
+
}
|
|
11
|
+
const guardFns = [];
|
|
12
|
+
const constraintsTable = [];
|
|
13
|
+
for (const c of constraints) {
|
|
14
|
+
const transpiled = transpilePhase2Guard(
|
|
15
|
+
c.requires.name,
|
|
16
|
+
c.requires.params ?? `self: any, actor: any`,
|
|
17
|
+
c.requires.body
|
|
18
|
+
);
|
|
19
|
+
const rewritten = rewriteSubqueriesAsync(transpiled.typescript, c.requires.name);
|
|
20
|
+
guardFns.push(rewritten);
|
|
21
|
+
const onArrayLiteral = `[${c.on.map((o) => JSON.stringify(o)).join(", ")}]`;
|
|
22
|
+
const sourceLiteral = JSON.stringify(
|
|
23
|
+
c.requires.source.input ?? ""
|
|
24
|
+
);
|
|
25
|
+
constraintsTable.push(
|
|
26
|
+
` {
|
|
27
|
+
on: ${onArrayLiteral},
|
|
28
|
+
guard: ${c.requires.name},
|
|
29
|
+
name: ${JSON.stringify(c.requires.name)},
|
|
30
|
+
source: ${sourceLiteral}
|
|
31
|
+
}`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
return `/**
|
|
35
|
+
* Auto-generated by SpecVerse Phase 2 (Validate-Centric Constraints).
|
|
36
|
+
*
|
|
37
|
+
* Guard functions for ${model.name}'s declared constraints.
|
|
38
|
+
* DO NOT EDIT \u2014 regenerated on every \`spv realize all\`.
|
|
39
|
+
*
|
|
40
|
+
* Slice 1 limitations:
|
|
41
|
+
* - \`self\` is the input payload (not the loaded entity). For Update/Delete/
|
|
42
|
+
* Evolve, paths through \`self\` see only the fields the caller sent.
|
|
43
|
+
* - \`actor\` defaults to null. Constraints using \`actor.*\` paths fail at
|
|
44
|
+
* runtime when called without a user context.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
export interface Violation {
|
|
48
|
+
constraint: string;
|
|
49
|
+
scope: string;
|
|
50
|
+
source: string;
|
|
51
|
+
message: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* GuardContext is provided by the controller's validate() method. The
|
|
56
|
+
* \`query\` accessor returns a per-model helper that backs subquery sugars
|
|
57
|
+
* like \`{Actor} has not {verb} on {Target}\` (which the transpiler converts
|
|
58
|
+
* to \`<Model>.some(predicate)\`). Implementations call the ORM's findMany
|
|
59
|
+
* (or the in-memory store for dynamic interpreters).
|
|
60
|
+
*/
|
|
61
|
+
export interface GuardContext {
|
|
62
|
+
query?: (modelName: string) => {
|
|
63
|
+
exists: (predicate: (entity: any) => boolean) => Promise<boolean>;
|
|
64
|
+
} | undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ConstraintRecord {
|
|
68
|
+
on: string[];
|
|
69
|
+
// Pure self/actor guards stay sync; subquery guards (those that reference
|
|
70
|
+
// bare-Capitalized model names like \`Vote.some(...)\`) are rewritten by
|
|
71
|
+
// the generator into async functions that take a third ctx argument.
|
|
72
|
+
// runGuards awaits the result either way.
|
|
73
|
+
guard: (self: any, actor: any, ctx?: GuardContext) => boolean | Promise<boolean>;
|
|
74
|
+
name: string;
|
|
75
|
+
source: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
${guardFns.join("\n\n")}
|
|
79
|
+
|
|
80
|
+
export const MODEL_CONSTRAINTS: ConstraintRecord[] = [
|
|
81
|
+
${constraintsTable.join(",\n")}
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Match a runtime operation against a constraint's \`on:\` array.
|
|
86
|
+
*
|
|
87
|
+
* Op shapes:
|
|
88
|
+
* - exact string: 'create', 'update', 'delete', 'retrieve'
|
|
89
|
+
* - 'evolve.<transition>': specific lifecycle transition
|
|
90
|
+
*
|
|
91
|
+
* \`on:\` entry shapes:
|
|
92
|
+
* - '*': always matches
|
|
93
|
+
* - 'evolve.*': matches any 'evolve.X' op
|
|
94
|
+
* - else: exact-string equality
|
|
95
|
+
*/
|
|
96
|
+
export function matchesOp(constraintOn: string[], op: string): boolean {
|
|
97
|
+
for (const o of constraintOn) {
|
|
98
|
+
if (o === '*') return true;
|
|
99
|
+
if (o === op) return true;
|
|
100
|
+
if (o === 'evolve.*' && op.startsWith('evolve.')) return true;
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Run all constraints whose \`on:\` matches the requested op. Each guard
|
|
107
|
+
* is invoked with (input, actor). Returns the collected violations.
|
|
108
|
+
*
|
|
109
|
+
* Failure-mode policy (fail-OPEN on exceptions):
|
|
110
|
+
* - Guard returns false \u2192 one Violation appended
|
|
111
|
+
* - Guard returns true \u2192 skip
|
|
112
|
+
* - Guard throws \u2192 console.error + treat as PASS (no violation)
|
|
113
|
+
*
|
|
114
|
+
* Rationale: a throw indicates a guard-internal defect (transpile bug,
|
|
115
|
+
* undefined-path traversal, type mismatch) rather than a real constraint
|
|
116
|
+
* failure. Blocking the mutation would deny legitimate user actions for
|
|
117
|
+
* a bug we already log loudly. Real data-integrity violations still fall
|
|
118
|
+
* back to database constraints + Slice 3 mode-\u03B3 preflight retries.
|
|
119
|
+
*/
|
|
120
|
+
export async function runGuards(
|
|
121
|
+
input: any,
|
|
122
|
+
op: string,
|
|
123
|
+
actor: any = null,
|
|
124
|
+
ctx: GuardContext | null = null,
|
|
125
|
+
): Promise<Violation[]> {
|
|
126
|
+
const violations: Violation[] = [];
|
|
127
|
+
for (const c of MODEL_CONSTRAINTS) {
|
|
128
|
+
if (!matchesOp(c.on, op)) continue;
|
|
129
|
+
let passed = true;
|
|
130
|
+
try {
|
|
131
|
+
// await wraps both sync (boolean) and async (Promise<boolean>) guards
|
|
132
|
+
// uniformly. Subquery guards need ctx; sync guards ignore it.
|
|
133
|
+
passed = await c.guard(input, actor, ctx ?? undefined);
|
|
134
|
+
} catch (e: any) {
|
|
135
|
+
// Fail-open: log loudly, do NOT block the mutation. See policy above.
|
|
136
|
+
console.error(
|
|
137
|
+
\`[runGuards] constraint "\${c.name}" (source: \${c.source}) threw during op="\${op}" \u2014 treating as PASS:\`,
|
|
138
|
+
e?.stack ?? e?.message ?? e,
|
|
139
|
+
);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (!passed) {
|
|
143
|
+
violations.push({
|
|
144
|
+
constraint: c.name,
|
|
145
|
+
scope: op,
|
|
146
|
+
source: c.source,
|
|
147
|
+
message: \`Constraint "\${c.source}" failed\`,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return violations;
|
|
152
|
+
}
|
|
153
|
+
`;
|
|
154
|
+
}
|
|
155
|
+
function generateEmptyGuardsModule(modelName) {
|
|
156
|
+
return `/**
|
|
157
|
+
* Auto-generated by SpecVerse Phase 2 \u2014 no constraints declared on ${modelName}.
|
|
158
|
+
* Empty stub kept for symmetry; controllers gate their imports on this file.
|
|
159
|
+
*/
|
|
160
|
+
|
|
161
|
+
export interface Violation {
|
|
162
|
+
constraint: string;
|
|
163
|
+
scope: string;
|
|
164
|
+
source: string;
|
|
165
|
+
message: string;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export const MODEL_CONSTRAINTS: any[] = [];
|
|
169
|
+
|
|
170
|
+
export function matchesOp(_constraintOn: string[], _op: string): boolean {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function runGuards(_input: any, _op: string, _actor: any = null, _ctx: any = null): Promise<Violation[]> {
|
|
175
|
+
return [];
|
|
176
|
+
}
|
|
177
|
+
`;
|
|
178
|
+
}
|
|
179
|
+
function rewriteSubqueriesAsync(transpiled, guardName) {
|
|
180
|
+
const subqueryPattern = /(?<![\w.])([A-Z][A-Za-z0-9_]*)\.some\(/g;
|
|
181
|
+
const matches = Array.from(transpiled.matchAll(subqueryPattern));
|
|
182
|
+
if (matches.length === 0) {
|
|
183
|
+
return transpiled;
|
|
184
|
+
}
|
|
185
|
+
const modelNames = Array.from(new Set(matches.map((m) => m[1])));
|
|
186
|
+
let body = transpiled.replace(subqueryPattern, (_match, modelName) => {
|
|
187
|
+
return `await __${modelName}.exists(`;
|
|
188
|
+
});
|
|
189
|
+
body = body.replace(
|
|
190
|
+
/export\s+function\s+(\w+)\(([^)]*)\)\s*:\s*boolean\s*\{/,
|
|
191
|
+
(_match, fnName, params) => {
|
|
192
|
+
return `export async function ${fnName}(${params}, ctx?: any): Promise<boolean> {`;
|
|
193
|
+
}
|
|
194
|
+
);
|
|
195
|
+
const shimLines = modelNames.map(
|
|
196
|
+
(m) => ` const __${m} = ctx?.query?.(${JSON.stringify(m)});
|
|
197
|
+
if (!__${m}) return true; // fail-open: no query ctx, skip subquery`
|
|
198
|
+
).join("\n");
|
|
199
|
+
body = body.replace(
|
|
200
|
+
/(:\s*Promise<boolean>\s*\{\n)/,
|
|
201
|
+
`$1${shimLines}
|
|
202
|
+
`
|
|
203
|
+
);
|
|
204
|
+
void guardName;
|
|
205
|
+
return body;
|
|
206
|
+
}
|
|
207
|
+
export {
|
|
208
|
+
generatePrismaGuards as default
|
|
209
|
+
};
|
package/dist/libs/instance-factories/services/templates/mongodb-native/controller-generator.js
CHANGED
|
@@ -15,12 +15,13 @@ function generateMongoNativeController(context) {
|
|
|
15
15
|
Object.assign(modelRegistry, models);
|
|
16
16
|
}
|
|
17
17
|
const customActions = generateCustomActions(controller, modelRegistry);
|
|
18
|
-
const
|
|
19
|
-
const
|
|
18
|
+
const hasConstraints = Array.isArray(model.constraints) && model.constraints.length > 0;
|
|
19
|
+
const validate = generateValidateMethod(model, modelName, hasConstraints);
|
|
20
|
+
const create = curedOps.create ? generateCreateMethod(model, modelName, modelVar, collection, hasConstraints) : "";
|
|
20
21
|
const retrieve = curedOps.retrieve ? generateRetrieveMethod(modelName, modelVar, collection) : "";
|
|
21
|
-
const update = curedOps.update ? generateUpdateMethod(modelName, modelVar, collection) : "";
|
|
22
|
-
const evolve = curedOps.evolve ? generateEvolveMethod(model, modelName, modelVar, collection) : "";
|
|
23
|
-
const del = curedOps.delete ? generateDeleteMethod(modelName, modelVar, collection) : "";
|
|
22
|
+
const update = curedOps.update ? generateUpdateMethod(modelName, modelVar, collection, hasConstraints) : "";
|
|
23
|
+
const evolve = curedOps.evolve ? generateEvolveMethod(model, modelName, modelVar, collection, hasConstraints) : "";
|
|
24
|
+
const del = curedOps.delete ? generateDeleteMethod(modelName, modelVar, collection, hasConstraints) : "";
|
|
24
25
|
const hasEventPublishing = curedOps.create || curedOps.update || curedOps.evolve || curedOps.delete;
|
|
25
26
|
return `/**
|
|
26
27
|
* ${controllerName}
|
|
@@ -31,6 +32,7 @@ import { ObjectId, type Filter, type Document } from 'mongodb';
|
|
|
31
32
|
import { getCollection } from '../db/mongoClient.js';
|
|
32
33
|
${hasEventPublishing || customActions.needsAiBehaviors ? `import { eventBus } from '../events/eventBus.js';` : ""}
|
|
33
34
|
${customActions.needsAiBehaviors ? `import * as aiBehaviors from '../behaviors/${controllerName}.ai.js';` : ""}
|
|
35
|
+
${hasConstraints ? `import { runGuards as runConstraintGuards } from './${modelName}.guards.js';` : ""}
|
|
34
36
|
|
|
35
37
|
const COLLECTION_NAME = '${collection}';
|
|
36
38
|
|
|
@@ -67,17 +69,33 @@ function collectionName(model) {
|
|
|
67
69
|
if (model?.storage?.collection) return String(model.storage.collection);
|
|
68
70
|
return model.name.toLowerCase() + "s";
|
|
69
71
|
}
|
|
70
|
-
function generateValidateMethod(model, modelName) {
|
|
72
|
+
function generateValidateMethod(model, modelName, hasConstraints) {
|
|
73
|
+
const opTypeUnion = hasConstraints ? `'create' | 'update' | 'evolve' | 'delete' | \`evolve.\${string}\`` : `'create' | 'update' | 'evolve'`;
|
|
74
|
+
const constraintsCheck = hasConstraints ? `
|
|
75
|
+
// Phase 2 \u2014 run model.constraints[] guards matching this operation.
|
|
76
|
+
// Slice 15b \u2014 ctx carries a per-model query helper for subquery sugars.
|
|
77
|
+
const __guardCtx = {
|
|
78
|
+
query: (modelName: string) => ({
|
|
79
|
+
exists: async (predicate: (e: any) => boolean) => {
|
|
80
|
+
const __col = await getCollection(modelName.toLowerCase() + 's');
|
|
81
|
+
const all = await __col.find({}).toArray();
|
|
82
|
+
return all.some(predicate);
|
|
83
|
+
},
|
|
84
|
+
}),
|
|
85
|
+
};
|
|
86
|
+
const constraintViolations = await runConstraintGuards(_data, _context.operation, _actor, __guardCtx);
|
|
87
|
+
for (const v of constraintViolations) errors.push(v.message);` : "";
|
|
71
88
|
return `
|
|
72
89
|
/**
|
|
73
|
-
* Validate ${modelName} data \u2014 runs before create / update / evolve.
|
|
90
|
+
* Validate ${modelName} data \u2014 runs before create / update / evolve / delete.
|
|
74
91
|
*/
|
|
75
|
-
public validate(
|
|
92
|
+
public async validate(
|
|
76
93
|
_data: any,
|
|
77
|
-
_context: { operation:
|
|
78
|
-
|
|
94
|
+
_context: { operation: ${opTypeUnion} },
|
|
95
|
+
_actor: any = null
|
|
96
|
+
): Promise<{ valid: boolean; errors: string[] }> {
|
|
79
97
|
const errors: string[] = [];
|
|
80
|
-
${generateValidationLogic(model)}
|
|
98
|
+
${generateValidationLogic(model)}${constraintsCheck}
|
|
81
99
|
return { valid: errors.length === 0, errors };
|
|
82
100
|
}
|
|
83
101
|
`;
|
|
@@ -104,13 +122,24 @@ function generateValidationLogic(model) {
|
|
|
104
122
|
});
|
|
105
123
|
return out.join("\n") || " // No validation rules defined";
|
|
106
124
|
}
|
|
107
|
-
function generateCreateMethod(model, modelName, modelVar, collection) {
|
|
125
|
+
function generateCreateMethod(model, modelName, modelVar, collection, hasConstraints = false) {
|
|
126
|
+
const belongsToLoad = hasConstraints ? generateMongoBelongsToLoad(model) : "";
|
|
127
|
+
const validateSelf = belongsToLoad ? "__mergedSelf" : "data";
|
|
108
128
|
return `
|
|
109
129
|
/**
|
|
110
130
|
* Create a new ${modelName}.
|
|
111
131
|
*/
|
|
112
|
-
public async create(data: any): Promise<any> {
|
|
113
|
-
|
|
132
|
+
public async create(data: any, _actor: any = null): Promise<any> {
|
|
133
|
+
${belongsToLoad ? `
|
|
134
|
+
// Phase 2 Slice 15 \u2014 Create-time relation loading. Mirrors prisma's
|
|
135
|
+
// pattern: for each belongsTo FK in input, load the related doc and
|
|
136
|
+
// merge into self so constraint guards traversing self.<rel> see
|
|
137
|
+
// the full related entity, not just the FK id.
|
|
138
|
+
const __loadedRels: Record<string, any> = {};
|
|
139
|
+
${belongsToLoad}
|
|
140
|
+
const __mergedSelf = { ...data, ...__loadedRels };
|
|
141
|
+
` : ""}
|
|
142
|
+
const validation = await this.validate(${validateSelf}, { operation: 'create' }, _actor);
|
|
114
143
|
if (!validation.valid) throw new Error(\`Validation failed: \${validation.errors.join(', ')}\`);
|
|
115
144
|
|
|
116
145
|
const collection = await getCollection(COLLECTION_NAME);
|
|
@@ -122,6 +151,20 @@ function generateCreateMethod(model, modelName, modelVar, collection) {
|
|
|
122
151
|
}
|
|
123
152
|
`;
|
|
124
153
|
}
|
|
154
|
+
function generateMongoBelongsToLoad(model) {
|
|
155
|
+
const rels = Array.isArray(model.relationships) ? model.relationships : Object.values(model.relationships || {});
|
|
156
|
+
const belongsToRels = rels.filter((r) => r.type === "belongsTo");
|
|
157
|
+
if (belongsToRels.length === 0) return "";
|
|
158
|
+
return belongsToRels.map((rel) => {
|
|
159
|
+
const relName = rel.name;
|
|
160
|
+
const targetName = rel.target;
|
|
161
|
+
const fkField = `${relName}Id`;
|
|
162
|
+
return `if (data.${fkField}) {
|
|
163
|
+
const __col_${relName} = await getCollection('${targetName.toLowerCase()}s');
|
|
164
|
+
__loadedRels.${relName} = await __col_${relName}.findOne(byId(data.${fkField}));
|
|
165
|
+
}`;
|
|
166
|
+
}).join("\n ");
|
|
167
|
+
}
|
|
125
168
|
function generateRetrieveMethod(modelName, modelVar, collection) {
|
|
126
169
|
return `
|
|
127
170
|
/**
|
|
@@ -144,13 +187,23 @@ function generateRetrieveMethod(modelName, modelVar, collection) {
|
|
|
144
187
|
}
|
|
145
188
|
`;
|
|
146
189
|
}
|
|
147
|
-
function generateUpdateMethod(modelName, modelVar, collection) {
|
|
190
|
+
function generateUpdateMethod(modelName, modelVar, collection, hasConstraints = false) {
|
|
148
191
|
return `
|
|
149
192
|
/**
|
|
150
193
|
* Update ${modelName}.
|
|
151
194
|
*/
|
|
152
|
-
public async update(id: string, data: any): Promise<any> {
|
|
153
|
-
const
|
|
195
|
+
public async update(id: string, data: any, _actor: any = null): Promise<any> {
|
|
196
|
+
const collection = await getCollection(COLLECTION_NAME);
|
|
197
|
+
${hasConstraints ? `
|
|
198
|
+
// Phase 2 \u2014 Update self-from-DB. Load + merge before validate so
|
|
199
|
+
// update-time constraints see the full entity, not just the partial input.
|
|
200
|
+
const __existing = await collection.findOne(byId(id));
|
|
201
|
+
if (!__existing) throw new Error('${modelName} not found');
|
|
202
|
+
const __merged = { ...__existing, ...data };
|
|
203
|
+
const validation = await this.validate(__merged, { operation: 'update' }, _actor);
|
|
204
|
+
` : `
|
|
205
|
+
const validation = await this.validate(data, { operation: 'update' }, _actor);
|
|
206
|
+
`}
|
|
154
207
|
if (!validation.valid) throw new Error(\`Validation failed: \${validation.errors.join(', ')}\`);
|
|
155
208
|
|
|
156
209
|
// Strip nested objects + id \u2014 only scalar fields are written.
|
|
@@ -162,7 +215,6 @@ function generateUpdateMethod(modelName, modelVar, collection) {
|
|
|
162
215
|
updateData[key] = value;
|
|
163
216
|
}
|
|
164
217
|
|
|
165
|
-
const collection = await getCollection(COLLECTION_NAME);
|
|
166
218
|
await collection.updateOne(byId(id), { $set: updateData });
|
|
167
219
|
const ${modelVar} = await collection.findOne(byId(id));
|
|
168
220
|
if (!${modelVar}) throw new Error('${modelName} not found after update');
|
|
@@ -172,7 +224,7 @@ function generateUpdateMethod(modelName, modelVar, collection) {
|
|
|
172
224
|
}
|
|
173
225
|
`;
|
|
174
226
|
}
|
|
175
|
-
function generateEvolveMethod(model, modelName, modelVar, collection) {
|
|
227
|
+
function generateEvolveMethod(model, modelName, modelVar, collection, hasConstraints = false) {
|
|
176
228
|
const lifecycles = Array.isArray(model.lifecycles) ? model.lifecycles : model.lifecycles ? Object.entries(model.lifecycles).map(([name, lc]) => ({ name, ...lc })) : [];
|
|
177
229
|
const lifecycle = lifecycles[0];
|
|
178
230
|
const lifecycleName = lifecycle?.name || "status";
|
|
@@ -181,12 +233,23 @@ function generateEvolveMethod(model, modelName, modelVar, collection) {
|
|
|
181
233
|
...Object.keys(validTransitions),
|
|
182
234
|
...Object.values(validTransitions).flat()
|
|
183
235
|
]));
|
|
236
|
+
const evolveOpsMap = {};
|
|
237
|
+
if (lifecycle?.transitions) {
|
|
238
|
+
for (const [actionName, t] of Object.entries(lifecycle.transitions)) {
|
|
239
|
+
const from = t.from;
|
|
240
|
+
const to = t.to;
|
|
241
|
+
if (typeof from === "string" && typeof to === "string") {
|
|
242
|
+
evolveOpsMap[from] = evolveOpsMap[from] ?? {};
|
|
243
|
+
evolveOpsMap[from][to] = actionName;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
184
247
|
return `
|
|
185
248
|
/**
|
|
186
249
|
* Evolve ${modelName} through lifecycle "${lifecycleName}"
|
|
187
250
|
* States: ${states.join(" \u2192 ") || "(none declared)"}
|
|
188
251
|
*/
|
|
189
|
-
public async evolve(id: string, data: any): Promise<any> {
|
|
252
|
+
public async evolve(id: string, data: any, _actor: any = null): Promise<any> {
|
|
190
253
|
const collection = await getCollection(COLLECTION_NAME);
|
|
191
254
|
const current = await collection.findOne(byId(id));
|
|
192
255
|
if (!current) throw new Error('${modelName} not found');
|
|
@@ -203,7 +266,22 @@ function generateEvolveMethod(model, modelName, modelVar, collection) {
|
|
|
203
266
|
throw new Error(\`Invalid transition: \${currentState} \u2192 \${targetState}. Allowed: \${allowed.join(', ') || 'none'}\`);
|
|
204
267
|
}
|
|
205
268
|
` : ""}
|
|
206
|
-
|
|
269
|
+
${hasConstraints ? `
|
|
270
|
+
// Phase 2 \u2014 resolve the transition's action name so constraints
|
|
271
|
+
// scoped to \`on: 'evolve.<action>'\` match correctly. EVOLVE_OPS is
|
|
272
|
+
// baked at codegen from the lifecycle definition.
|
|
273
|
+
const EVOLVE_OPS: Record<string, Record<string, string>> = ${JSON.stringify(evolveOpsMap)};
|
|
274
|
+
const currentStateForOp = (current as any)[targetLifecycle];
|
|
275
|
+
const actionName = EVOLVE_OPS[currentStateForOp]?.[targetState] ?? targetState;
|
|
276
|
+
const evolveValidation = await this.validate(
|
|
277
|
+
{ ...data, ...current, [targetLifecycle]: targetState },
|
|
278
|
+
{ operation: \`evolve.\${actionName}\` as any },
|
|
279
|
+
_actor
|
|
280
|
+
);
|
|
281
|
+
if (!evolveValidation.valid) {
|
|
282
|
+
throw new Error(\`Validation failed: \${evolveValidation.errors.join(', ')}\`);
|
|
283
|
+
}
|
|
284
|
+
` : ""}
|
|
207
285
|
await collection.updateOne(byId(id), { $set: { [targetLifecycle]: targetState } });
|
|
208
286
|
const ${modelVar} = await collection.findOne(byId(id));
|
|
209
287
|
if (!${modelVar}) throw new Error('${modelName} not found after evolve');
|
|
@@ -213,14 +291,23 @@ function generateEvolveMethod(model, modelName, modelVar, collection) {
|
|
|
213
291
|
}
|
|
214
292
|
`;
|
|
215
293
|
}
|
|
216
|
-
function generateDeleteMethod(modelName, modelVar, collection) {
|
|
294
|
+
function generateDeleteMethod(modelName, modelVar, collection, hasConstraints = false) {
|
|
217
295
|
return `
|
|
218
296
|
/**
|
|
219
297
|
* Delete ${modelName}.
|
|
220
298
|
*/
|
|
221
|
-
public async delete(id: string): Promise<void> {
|
|
299
|
+
public async delete(id: string, _actor: any = null): Promise<void> {
|
|
222
300
|
const collection = await getCollection(COLLECTION_NAME);
|
|
223
301
|
const ${modelVar} = await collection.findOne(byId(id));
|
|
302
|
+
${hasConstraints ? `
|
|
303
|
+
// Phase 2 \u2014 run delete-scoped constraint guards against the loaded record.
|
|
304
|
+
if (${modelVar}) {
|
|
305
|
+
const deleteValidation = await this.validate(${modelVar}, { operation: 'delete' }, _actor);
|
|
306
|
+
if (!deleteValidation.valid) {
|
|
307
|
+
throw new Error(\`Validation failed: \${deleteValidation.errors.join(', ')}\`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
` : ""}
|
|
224
311
|
await collection.deleteOne(byId(id));
|
|
225
312
|
if (${modelVar}) {
|
|
226
313
|
await eventBus.publish('${modelName}Deleted', { ...${modelVar}, timestamp: new Date().toISOString() } as any);
|