@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.
Files changed (42) 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/libs/instance-factories/controllers/templates/fastify/routes-generator.js +14 -5
  10. package/dist/libs/instance-factories/services/mongodb-native-services.yaml +10 -0
  11. package/dist/libs/instance-factories/services/postgres-native-services.yaml +10 -0
  12. package/dist/libs/instance-factories/services/prisma-services.yaml +10 -0
  13. package/dist/libs/instance-factories/services/templates/_shared/guards-generator.js +209 -0
  14. package/dist/libs/instance-factories/services/templates/mongodb-native/controller-generator.js +110 -23
  15. package/dist/libs/instance-factories/services/templates/postgres-native/controller-generator.js +104 -22
  16. package/dist/libs/instance-factories/services/templates/prisma/controller-generator.js +133 -23
  17. package/dist/libs/instance-factories/services/templates/prisma/guards-generator.js +151 -0
  18. package/dist/parser/convention-processor.d.ts +44 -1
  19. package/dist/parser/convention-processor.d.ts.map +1 -1
  20. package/dist/parser/convention-processor.js +175 -1
  21. package/dist/parser/convention-processor.js.map +1 -1
  22. package/dist/parser/types/ast.d.ts +1 -1
  23. package/dist/parser/types/ast.d.ts.map +1 -1
  24. package/dist/parser/unified-parser.d.ts.map +1 -1
  25. package/dist/parser/unified-parser.js +25 -2
  26. package/dist/parser/unified-parser.js.map +1 -1
  27. package/dist/realize/index.d.ts.map +1 -1
  28. package/dist/realize/index.js +17 -0
  29. package/dist/realize/index.js.map +1 -1
  30. package/libs/instance-factories/controllers/templates/fastify/__tests__/actor-wiring.test.ts +80 -0
  31. package/libs/instance-factories/controllers/templates/fastify/routes-generator.ts +14 -5
  32. package/libs/instance-factories/services/mongodb-native-services.yaml +10 -0
  33. package/libs/instance-factories/services/postgres-native-services.yaml +10 -0
  34. package/libs/instance-factories/services/prisma-services.yaml +10 -0
  35. package/libs/instance-factories/services/templates/_shared/guards-generator.ts +296 -0
  36. package/libs/instance-factories/services/templates/mongodb-native/__tests__/controller-with-constraints.test.ts +192 -0
  37. package/libs/instance-factories/services/templates/mongodb-native/controller-generator.ts +144 -23
  38. package/libs/instance-factories/services/templates/postgres-native/__tests__/controller-with-constraints.test.ts +192 -0
  39. package/libs/instance-factories/services/templates/postgres-native/controller-generator.ts +130 -22
  40. package/libs/instance-factories/services/templates/prisma/__tests__/controller-with-constraints.test.ts +261 -0
  41. package/libs/instance-factories/services/templates/prisma/controller-generator.ts +186 -22
  42. 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
+ });