@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.
Files changed (53) hide show
  1. package/dist/inference/index.d.ts +1 -1
  2. package/dist/inference/index.d.ts.map +1 -1
  3. package/dist/inference/index.js +1 -1
  4. package/dist/inference/index.js.map +1 -1
  5. package/dist/inference/quint-transpiler.d.ts +18 -0
  6. package/dist/inference/quint-transpiler.d.ts.map +1 -1
  7. package/dist/inference/quint-transpiler.js +32 -0
  8. package/dist/inference/quint-transpiler.js.map +1 -1
  9. package/dist/inference/ui-contracts/index.d.ts.map +1 -1
  10. package/dist/inference/ui-contracts/index.js +2 -0
  11. package/dist/inference/ui-contracts/index.js.map +1 -1
  12. package/dist/inference/ui-contracts/rules/_shared.d.ts +8 -0
  13. package/dist/inference/ui-contracts/rules/_shared.d.ts.map +1 -1
  14. package/dist/inference/ui-contracts/rules/_shared.js +20 -0
  15. package/dist/inference/ui-contracts/rules/_shared.js.map +1 -1
  16. package/dist/inference/ui-contracts/rules/belongsto-shows-name-in-list.d.ts +29 -0
  17. package/dist/inference/ui-contracts/rules/belongsto-shows-name-in-list.d.ts.map +1 -0
  18. package/dist/inference/ui-contracts/rules/belongsto-shows-name-in-list.js +88 -0
  19. package/dist/inference/ui-contracts/rules/belongsto-shows-name-in-list.js.map +1 -0
  20. package/dist/libs/instance-factories/controllers/templates/fastify/routes-generator.js +14 -5
  21. package/dist/libs/instance-factories/services/mongodb-native-services.yaml +10 -0
  22. package/dist/libs/instance-factories/services/postgres-native-services.yaml +10 -0
  23. package/dist/libs/instance-factories/services/prisma-services.yaml +10 -0
  24. package/dist/libs/instance-factories/services/templates/_shared/guards-generator.js +209 -0
  25. package/dist/libs/instance-factories/services/templates/mongodb-native/controller-generator.js +110 -23
  26. package/dist/libs/instance-factories/services/templates/postgres-native/controller-generator.js +104 -22
  27. package/dist/libs/instance-factories/services/templates/prisma/controller-generator.js +133 -23
  28. package/dist/libs/instance-factories/services/templates/prisma/guards-generator.js +151 -0
  29. package/dist/parser/convention-processor.d.ts +44 -1
  30. package/dist/parser/convention-processor.d.ts.map +1 -1
  31. package/dist/parser/convention-processor.js +175 -1
  32. package/dist/parser/convention-processor.js.map +1 -1
  33. package/dist/parser/types/ast.d.ts +1 -1
  34. package/dist/parser/types/ast.d.ts.map +1 -1
  35. package/dist/parser/unified-parser.d.ts.map +1 -1
  36. package/dist/parser/unified-parser.js +25 -2
  37. package/dist/parser/unified-parser.js.map +1 -1
  38. package/dist/realize/index.d.ts.map +1 -1
  39. package/dist/realize/index.js +17 -0
  40. package/dist/realize/index.js.map +1 -1
  41. package/libs/instance-factories/controllers/templates/fastify/__tests__/actor-wiring.test.ts +80 -0
  42. package/libs/instance-factories/controllers/templates/fastify/routes-generator.ts +14 -5
  43. package/libs/instance-factories/services/mongodb-native-services.yaml +10 -0
  44. package/libs/instance-factories/services/postgres-native-services.yaml +10 -0
  45. package/libs/instance-factories/services/prisma-services.yaml +10 -0
  46. package/libs/instance-factories/services/templates/_shared/guards-generator.ts +296 -0
  47. package/libs/instance-factories/services/templates/mongodb-native/__tests__/controller-with-constraints.test.ts +192 -0
  48. package/libs/instance-factories/services/templates/mongodb-native/controller-generator.ts +144 -23
  49. package/libs/instance-factories/services/templates/postgres-native/__tests__/controller-with-constraints.test.ts +192 -0
  50. package/libs/instance-factories/services/templates/postgres-native/controller-generator.ts +130 -22
  51. package/libs/instance-factories/services/templates/prisma/__tests__/controller-with-constraints.test.ts +261 -0
  52. package/libs/instance-factories/services/templates/prisma/controller-generator.ts +186 -22
  53. 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
- const ${lowerModel} = await handler.create(request.body as any);
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 ${lowerModel} = await handler.${operation}(id, request.body as any);
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
- await handler.delete(id);
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 result = handler.validate(data, { operation: op });
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
+ };
@@ -15,12 +15,13 @@ function generateMongoNativeController(context) {
15
15
  Object.assign(modelRegistry, models);
16
16
  }
17
17
  const customActions = generateCustomActions(controller, modelRegistry);
18
- const validate = generateValidateMethod(model, modelName);
19
- const create = curedOps.create ? generateCreateMethod(model, modelName, modelVar, collection) : "";
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: 'create' | 'update' | 'evolve' }
78
- ): { valid: boolean; errors: string[] } {
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
- const validation = this.validate(data, { operation: 'create' });
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 validation = this.validate(data, { operation: 'update' });
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);