@specverse/engines 6.6.3 → 6.11.2

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 (29) hide show
  1. package/dist/inference/core/specly-converter.d.ts.map +1 -1
  2. package/dist/inference/core/specly-converter.js +20 -0
  3. package/dist/inference/core/specly-converter.js.map +1 -1
  4. package/dist/inference/index.d.ts.map +1 -1
  5. package/dist/inference/index.js +72 -22
  6. package/dist/inference/index.js.map +1 -1
  7. package/dist/inference/logical/generators/controller-generator.d.ts.map +1 -1
  8. package/dist/inference/logical/generators/controller-generator.js +26 -4
  9. package/dist/inference/logical/generators/controller-generator.js.map +1 -1
  10. package/dist/libs/instance-factories/applications/templates/generic/backend-package-json-generator.js +26 -10
  11. package/dist/libs/instance-factories/controllers/templates/fastify/routes-generator.js +50 -15
  12. package/dist/libs/instance-factories/controllers/templates/fastify/server-generator.js +27 -7
  13. package/dist/libs/instance-factories/services/templates/mongodb-native/controller-generator.js +59 -23
  14. package/dist/libs/instance-factories/services/templates/mongodb-native/step-conventions.js +319 -0
  15. package/dist/libs/instance-factories/services/templates/prisma/ai-behaviors-generator.js +192 -28
  16. package/dist/parser/processors/ExecutableProcessor.d.ts.map +1 -1
  17. package/dist/parser/processors/ExecutableProcessor.js +14 -1
  18. package/dist/parser/processors/ExecutableProcessor.js.map +1 -1
  19. package/dist/realize/index.d.ts.map +1 -1
  20. package/dist/realize/index.js +22 -3
  21. package/dist/realize/index.js.map +1 -1
  22. package/libs/instance-factories/applications/templates/generic/backend-package-json-generator.ts +48 -12
  23. package/libs/instance-factories/controllers/templates/fastify/routes-generator.ts +80 -21
  24. package/libs/instance-factories/controllers/templates/fastify/server-generator.ts +49 -8
  25. package/libs/instance-factories/services/templates/mongodb-native/__tests__/controller-generator.test.ts +3 -1
  26. package/libs/instance-factories/services/templates/mongodb-native/controller-generator.ts +82 -25
  27. package/libs/instance-factories/services/templates/mongodb-native/step-conventions.ts +423 -0
  28. package/libs/instance-factories/services/templates/prisma/ai-behaviors-generator.ts +287 -38
  29. package/package.json +6 -6
@@ -1,15 +1,56 @@
1
1
  /**
2
2
  * Backend Package.json Generator
3
3
  *
4
- * Generates package.json for backend workspace in monorepo
4
+ * Generates package.json for backend workspace in monorepo. Adapts deps +
5
+ * scripts to the manifest's resolved ORM/storage so a manifest pinning
6
+ * MongoDBNativeDriver doesn't get stuck with Prisma scripts and
7
+ * `@prisma/client` in dependencies.
5
8
  */
6
9
 
7
10
  import type { TemplateContext } from '@specverse/types';
8
11
 
9
- export default function generateBackendPackageJson(context: TemplateContext): string {
10
- const { spec } = context;
12
+ /** Read the manifest's resolved orm name (e.g. "PrismaORM", "MongoDBNativeDriver"). */
13
+ function resolveOrmName(manifest: any): string {
14
+ if (!manifest) return 'PrismaORM';
15
+ const inner = manifest.manifests
16
+ ? Object.values(manifest.manifests)[0] as any
17
+ : manifest;
18
+ if (!inner) return 'PrismaORM';
19
+ // Prefer explicit capabilityMappings for orm.client; fall back to defaultMappings.orm.
20
+ const caps = Array.isArray(inner.capabilityMappings) ? inner.capabilityMappings : [];
21
+ const ormCap = caps.find((m: any) => m?.capability === 'orm.client') ||
22
+ caps.find((m: any) => m?.capability === 'orm.schema');
23
+ if (ormCap) return ormCap.implementation || ormCap.instanceFactory || 'PrismaORM';
24
+ return inner.defaultMappings?.orm || 'PrismaORM';
25
+ }
11
26
 
27
+ export default function generateBackendPackageJson(context: TemplateContext): string {
28
+ const { spec, manifest } = context as any;
12
29
  const appName = (spec.metadata?.component || 'app').toLowerCase().replace(/\s+/g, '-');
30
+ const orm = resolveOrmName(manifest);
31
+ const isMongoNative = orm === 'MongoDBNativeDriver';
32
+
33
+ // Database scripts are ORM-specific. Prisma has db:setup/generate/push;
34
+ // MongoDB native has nothing to generate (collections are dynamic), so
35
+ // we drop those scripts entirely rather than emit no-op placeholders.
36
+ const dbScripts: Record<string, string> = isMongoNative
37
+ ? {}
38
+ : {
39
+ 'db:setup': 'prisma generate && prisma db push',
40
+ 'db:generate': 'prisma generate',
41
+ 'db:push': 'prisma db push',
42
+ 'db:migrate': 'prisma migrate dev',
43
+ 'db:studio': 'prisma studio',
44
+ 'db:seed': 'tsx prisma/seed.ts',
45
+ };
46
+
47
+ const ormDeps: Record<string, string> = isMongoNative
48
+ ? { 'mongodb': '^6.3.0' }
49
+ : { '@prisma/client': '^5.7.0' };
50
+
51
+ const ormDevDeps: Record<string, string> = isMongoNative
52
+ ? {}
53
+ : { 'prisma': '^5.7.0' };
13
54
 
14
55
  const pkg: Record<string, any> = {
15
56
  name: `${appName}-backend`,
@@ -32,13 +73,8 @@ export default function generateBackendPackageJson(context: TemplateContext): st
32
73
  // Production
33
74
  'start': 'node dist/main.js',
34
75
 
35
- // Database
36
- 'db:setup': 'prisma generate && prisma db push',
37
- 'db:generate': 'prisma generate',
38
- 'db:push': 'prisma db push',
39
- 'db:migrate': 'prisma migrate dev',
40
- 'db:studio': 'prisma studio',
41
- 'db:seed': 'tsx prisma/seed.ts',
76
+ // Database (ORM-specific; empty for native driver)
77
+ ...dbScripts,
42
78
 
43
79
  // Testing
44
80
  'test': 'vitest run --passWithNoTests',
@@ -54,7 +90,7 @@ export default function generateBackendPackageJson(context: TemplateContext): st
54
90
  },
55
91
 
56
92
  dependencies: {
57
- '@prisma/client': '^5.7.0',
93
+ ...ormDeps,
58
94
  'fastify': '^5.8.3',
59
95
  '@fastify/cors': '^10.0.0',
60
96
  '@fastify/helmet': '^12.0.0',
@@ -70,7 +106,7 @@ export default function generateBackendPackageJson(context: TemplateContext): st
70
106
  'typescript': '^5.3.0',
71
107
  '@types/node': '^20.10.0',
72
108
  'tsx': '^4.7.0',
73
- 'prisma': '^5.7.0',
109
+ ...ormDevDeps,
74
110
  'vitest': '^3.0.0',
75
111
  '@vitest/coverage-v8': '^3.0.0',
76
112
  'eslint': '^9.0.0',
@@ -36,16 +36,26 @@ export default function generateFastifyRoutes(context: TemplateContext): string
36
36
  }
37
37
  }
38
38
 
39
- // Add custom action endpoints
39
+ // Add custom action endpoints. Each action can declare:
40
+ // path: explicit HTTP path (absolute or relative)
41
+ // method: explicit HTTP method
42
+ // If `path` starts with `/api/` (or any absolute non-relative form), it
43
+ // ESCAPES the controller's prefix and gets mounted at root level via the
44
+ // `registerExternalRoutes` exported function (the main.ts generator
45
+ // collects these across controllers and registers them without a prefix).
40
46
  if (controller.actions) {
41
47
  if (!endpoints) endpoints = [];
42
48
  for (const [actionName, action] of Object.entries(controller.actions) as [string, any][]) {
43
49
  const params = action.parameters || {};
44
50
  const hasIdParam = Object.keys(params).some(p => p === 'id' || p === `${modelName?.charAt(0).toLowerCase()}${modelName?.slice(1)}Id`);
51
+ const explicitPath: string | undefined = action.path;
52
+ const explicitMethod: string | undefined = action.method;
53
+ const isExternal = !!(explicitPath && /^\/api\//.test(explicitPath));
45
54
  endpoints.push({
46
55
  operation: actionName,
47
- method: 'POST',
48
- path: hasIdParam ? `/:id/${actionName}` : `/${actionName}`,
56
+ method: explicitMethod || 'POST',
57
+ path: explicitPath || (hasIdParam ? `/:id/${actionName}` : `/${actionName}`),
58
+ external: isExternal,
49
59
  parameters: params,
50
60
  description: action.description || `Custom action: ${actionName}`,
51
61
  });
@@ -57,9 +67,41 @@ export default function generateFastifyRoutes(context: TemplateContext): string
57
67
  console.warn(`Warning: Controller ${controllerName} has no endpoints. Generating empty routes file.`);
58
68
  }
59
69
 
60
- const routeHandlers = endpoints?.map((endpoint: any) => {
61
- return generateRouteHandler(endpoint, modelName, handlerName, isModelController, implType, controllerName);
62
- }).join('\n\n') || '';
70
+ // Split endpoints by routing scope:
71
+ // - internal: mounted under the controller's `/api/<plural>` prefix
72
+ // (the default plugin export). Both CURVED ops and relative-path
73
+ // custom actions live here.
74
+ // - external: actions that declared an absolute `path` like
75
+ // `/api/v2/auth/register`. Mounted at root via the separately
76
+ // exported `registerExternalRoutes` function so they escape the
77
+ // controller prefix.
78
+ const internalEndpoints = endpoints?.filter((e: any) => !e.external) || [];
79
+ const externalEndpoints = endpoints?.filter((e: any) => e.external) || [];
80
+
81
+ const internalHandlers = internalEndpoints
82
+ .map((endpoint: any) => generateRouteHandler(endpoint, modelName, handlerName, isModelController, implType, controllerName))
83
+ .join('\n\n');
84
+ const externalHandlers = externalEndpoints
85
+ .map((endpoint: any) => generateRouteHandler(endpoint, modelName, handlerName, isModelController, implType, controllerName))
86
+ .join('\n\n');
87
+
88
+ const externalRoutesExport = externalEndpoints.length > 0 ? `
89
+ /**
90
+ * Mount the external (root-prefix) routes for this controller.
91
+ *
92
+ * Called by the generated main.ts at root scope (no prefix) so action.path
93
+ * declarations like '/api/v2/auth/register' land at exactly that URL,
94
+ * bypassing the controller's '/api/<plural>' prefix.
95
+ */
96
+ export async function registerExternalRoutes(
97
+ fastify: FastifyInstance,
98
+ options: any
99
+ ) {
100
+ const handler = ${isModelController ? 'options.controllers' : 'options.services'}.${handlerName};
101
+
102
+ ${externalHandlers.split('\n').map(line => ' ' + line).join('\n')}
103
+ }
104
+ ` : '';
63
105
 
64
106
  // Generate the complete route file
65
107
  return `${imports}
@@ -77,9 +119,9 @@ export default async function ${routeName.replace('Controller', '')}Routes(
77
119
  ) {
78
120
  const handler = ${isModelController ? 'options.controllers' : 'options.services'}.${handlerName};
79
121
 
80
- ${routeHandlers.split('\n').map(line => ' ' + line).join('\n')}
122
+ ${internalHandlers.split('\n').map(line => ' ' + line).join('\n')}
81
123
  }
82
- `;
124
+ ${externalRoutesExport}`;
83
125
  }
84
126
 
85
127
  /**
@@ -306,28 +348,38 @@ function generateHandlerBody(
306
348
  }`;
307
349
 
308
350
  default: {
309
- // Custom action — validate body against declared parameters, then call handler
351
+ // Custom action — merge URL params + body into a single `args` object
352
+ // and pass it to the handler. The merged shape lets the action method
353
+ // read fields uniformly without caring whether they came from `:id`
354
+ // in the path or the JSON body. Validation still fires on declared
355
+ // parameters but is skipped for any param name that's also a URL
356
+ // path parameter (those come from request.params, not the body).
357
+ const path = endpoint?.path || '';
358
+ const urlParamNames = new Set<string>();
359
+ const urlParamRegex = /:([A-Za-z_][\w]*)/g;
360
+ let urlMatch: RegExpExecArray | null;
361
+ while ((urlMatch = urlParamRegex.exec(path)) !== null) {
362
+ urlParamNames.add(urlMatch[1]);
363
+ }
364
+
310
365
  const params = endpoint?.parameters || {};
311
- const paramNames = Object.keys(params);
312
- const callArgs = paramNames.length > 0
313
- ? paramNames.map(p => `body.${p}`).join(', ')
314
- : 'body';
315
366
 
316
- // Generate per-parameter validation checks
367
+ // Generate per-parameter body validation, skipping URL-path-sourced params
317
368
  const validations: string[] = [];
318
369
  for (const [pName, pDef] of Object.entries(params) as [string, any][]) {
370
+ if (urlParamNames.has(pName)) continue;
319
371
  const required = typeof pDef === 'string' ? pDef.includes('required') : pDef?.required;
320
372
  const typeStr = typeof pDef === 'string' ? pDef.split(' ')[0] : pDef?.type || 'String';
321
373
  if (required) {
322
- validations.push(` if (body.${pName} === undefined || body.${pName} === null) errors.push({ field: '${pName}', message: '${pName} is required' });`);
374
+ validations.push(` if (args.${pName} === undefined || args.${pName} === null) errors.push({ field: '${pName}', message: '${pName} is required' });`);
323
375
  }
324
- // Type checks
376
+ // Type checks (skip when undefined — `required` covers the missing case)
325
377
  if (typeStr === 'UUID' || typeStr === 'String' || typeStr === 'Email') {
326
- validations.push(` if (body.${pName} !== undefined && typeof body.${pName} !== 'string') errors.push({ field: '${pName}', message: '${pName} must be a string' });`);
378
+ validations.push(` if (args.${pName} !== undefined && typeof args.${pName} !== 'string') errors.push({ field: '${pName}', message: '${pName} must be a string' });`);
327
379
  } else if (typeStr === 'Integer' || typeStr === 'Number' || typeStr === 'Float') {
328
- validations.push(` if (body.${pName} !== undefined && typeof body.${pName} !== 'number') errors.push({ field: '${pName}', message: '${pName} must be a number' });`);
380
+ validations.push(` if (args.${pName} !== undefined && typeof args.${pName} !== 'number') errors.push({ field: '${pName}', message: '${pName} must be a number' });`);
329
381
  } else if (typeStr === 'Boolean') {
330
- validations.push(` if (body.${pName} !== undefined && typeof body.${pName} !== 'boolean') errors.push({ field: '${pName}', message: '${pName} must be a boolean' });`);
382
+ validations.push(` if (args.${pName} !== undefined && typeof args.${pName} !== 'boolean') errors.push({ field: '${pName}', message: '${pName} must be a boolean' });`);
331
383
  }
332
384
  }
333
385
 
@@ -336,14 +388,21 @@ function generateHandlerBody(
336
388
  : '';
337
389
 
338
390
  return `try {
391
+ const params = (request.params || {}) as Record<string, any>;
339
392
  const body = (request.body || {}) as Record<string, any>;
393
+ const args = { ...params, ...body };
340
394
  ${validationBlock}
341
- const result = await handler.${operation}(${callArgs});
395
+ const result = await handler.${operation}(args);
342
396
  return reply.send(result || { success: true });
343
397
  } catch (error) {
398
+ const msg = error instanceof Error ? error.message : String(error);
399
+ // 501 for TODO-stubbed action bodies (#43F); 400 for real failures.
400
+ if (msg.includes('is not implemented')) {
401
+ return reply.status(501).send({ error: 'Not Implemented', message: msg });
402
+ }
344
403
  return reply.status(400).send({
345
404
  error: 'Failed to execute ${operation}',
346
- message: error instanceof Error ? error.message : String(error)
405
+ message: msg
347
406
  });
348
407
  }`;
349
408
  }
@@ -7,16 +7,39 @@
7
7
  import type { TemplateContext } from '@specverse/types';
8
8
  import { normalizeSpec, deriveBasePath } from '@specverse/types/spec-rules';
9
9
 
10
+ /** Resolve the manifest's orm choice so the server template adapts its
11
+ * imports + lifecycle hooks. When MongoDBNativeDriver is selected we
12
+ * drop the prisma import + prisma.$disconnect call (no prisma in deps);
13
+ * the mongo client has its own disconnect helper exposed by the
14
+ * client-generator. */
15
+ function resolveOrmName(manifest: any): string {
16
+ if (!manifest) return 'PrismaORM';
17
+ const inner = manifest.manifests
18
+ ? Object.values(manifest.manifests)[0] as any
19
+ : manifest;
20
+ if (!inner) return 'PrismaORM';
21
+ const caps = Array.isArray(inner.capabilityMappings) ? inner.capabilityMappings : [];
22
+ const ormCap = caps.find((m: any) => m?.capability === 'orm.client') ||
23
+ caps.find((m: any) => m?.capability === 'orm.schema');
24
+ if (ormCap) return ormCap.implementation || ormCap.instanceFactory || 'PrismaORM';
25
+ return inner.defaultMappings?.orm || 'PrismaORM';
26
+ }
27
+
10
28
  export default function generateFastifyServer(context: TemplateContext): string {
11
- const { spec, models } = context;
29
+ const { spec, models, manifest } = context as any;
30
+ const orm = resolveOrmName(manifest);
31
+ const isMongoNative = orm === 'MongoDBNativeDriver';
12
32
 
13
33
  // Extract model names for route registration
14
34
  const allModels = models || (spec?.models ? Object.values(spec.models) : []);
15
35
  const modelNames = allModels.map((m: any) => m.name).filter(Boolean);
16
36
 
17
- // Generate route imports and registrations
37
+ // Generate route imports and registrations. We import the routes module
38
+ // as a namespace so the optional `registerExternalRoutes` named export is
39
+ // available at runtime — its presence depends on whether the controller
40
+ // declared any actions with absolute `path:` (escape-the-prefix) routes.
18
41
  const routeImports = modelNames.map((name: string) =>
19
- `import ${name}Routes from './routes/${name}Controller.js';`
42
+ `import ${name}Routes, * as ${name}RoutesNS from './routes/${name}Controller.js';`
20
43
  ).join('\n');
21
44
 
22
45
  const routeRegistrations = modelNames.map((name: string) => {
@@ -24,6 +47,16 @@ export default function generateFastifyServer(context: TemplateContext): string
24
47
  return ` await fastify.register(${name}Routes, { prefix: '${path}', controllers: { ${name}Controller: new (await import('./controllers/${name}Controller.js')).${name}Controller() } });`;
25
48
  }).join('\n');
26
49
 
50
+ // External-route registrations: each controller's namespace may export
51
+ // `registerExternalRoutes` for action.path declarations like
52
+ // '/api/v2/auth/register' that need to escape the controller prefix.
53
+ // Mounted at root scope, no prefix, no plugin scoping.
54
+ const externalRouteRegistrations = modelNames.map((name: string) => {
55
+ return ` if (typeof (${name}RoutesNS as any).registerExternalRoutes === 'function') {
56
+ await (${name}RoutesNS as any).registerExternalRoutes(fastify, { controllers: { ${name}Controller: new (await import('./controllers/${name}Controller.js')).${name}Controller() } });
57
+ }`;
58
+ }).join('\n');
59
+
27
60
  // Check for events in spec
28
61
  const specEvents = spec.events ? Object.keys(spec.events) : [];
29
62
  const hasEvents = specEvents.length > 0 || modelNames.length > 0;
@@ -59,7 +92,7 @@ export default function generateFastifyServer(context: TemplateContext): string
59
92
  // workspace before running the script.
60
93
  import { config as loadEnv } from 'dotenv';
61
94
  import { existsSync } from 'fs';
62
- import { resolve as resolvePath, dirname, join } from 'path';
95
+ import { dirname, join } from 'path';
63
96
  import { fileURLToPath } from 'url';
64
97
  {
65
98
  let dir = dirname(fileURLToPath(import.meta.url));
@@ -72,12 +105,17 @@ import { fileURLToPath } from 'url';
72
105
 
73
106
  import Fastify from 'fastify';
74
107
  import cors from '@fastify/cors';
75
- import { PrismaClient } from '@prisma/client';
108
+ ${isMongoNative
109
+ ? `import { disconnect as disconnectMongo } from './db/mongoClient.js';`
110
+ : `import { PrismaClient } from '@prisma/client';`}
76
111
  ${hasEvents ? `import { eventBus } from './events/eventBus.js';
77
112
  import { registerWebSocketBridge } from './events/websocket-bridge.js';` : ''}
78
113
 
79
- // Initialize Prisma
80
- export const prisma = new PrismaClient();
114
+ ${isMongoNative
115
+ ? `// MongoDB native driver client lives in db/mongoClient.ts; nothing
116
+ // to initialize at module scope (lazy-connect on first getCollection).`
117
+ : `// Initialize Prisma
118
+ export const prisma = new PrismaClient();`}
81
119
 
82
120
  // Initialize Fastify
83
121
  const fastify = Fastify({
@@ -117,6 +155,9 @@ ${serviceSingletonImports ? `// Service singletons + their Fastify route files\n
117
155
  async function registerRoutes() {
118
156
  ${routeRegistrations}
119
157
  ${serviceRouteRegistrations ? '\n // Service operation routes (RPC-style under /api/services/{ServiceName})\n' + serviceRouteRegistrations : ''}
158
+
159
+ // External-prefix actions (action.path declarations like '/api/v2/auth/register')
160
+ ${externalRouteRegistrations}
120
161
  }
121
162
 
122
163
  // Start server
@@ -145,7 +186,7 @@ ${hasEvents ? ` console.log(\`WebSocket: ws://localhost:\${port}/ws\`);
145
186
  try {
146
187
  fastify.log.info(\`Received \${signal}, closing server...\`);
147
188
  await fastify.close();
148
- try { await (prisma as any).$disconnect?.(); } catch { /* ignore */ }
189
+ try { ${isMongoNative ? 'await disconnectMongo();' : 'await (prisma as any).$disconnect?.();'} } catch { /* ignore */ }
149
190
  process.exit(0);
150
191
  } catch (err) {
151
192
  fastify.log.error({ err }, 'Error during shutdown');
@@ -94,7 +94,9 @@ describe('MongoDB native — controller-generator', () => {
94
94
  model: { ...baseModel, storage: { collection: 'custom_todos' } },
95
95
  } as any);
96
96
  expect(out).toContain(`COLLECTION_NAME = 'custom_todos'`);
97
- expect(out).toContain(`getCollection('custom_todos')`);
97
+ // CURVED ops reference the constant, not the literal — keeps the
98
+ // generator output noUnusedLocals-clean under strict tsc.
99
+ expect(out).toContain(`getCollection(COLLECTION_NAME)`);
98
100
  });
99
101
 
100
102
  it('omits CURVED ops not declared on the controller', () => {
@@ -36,7 +36,7 @@ export default function generateMongoNativeController(context: TemplateContext):
36
36
  const collection = collectionName(model);
37
37
  const curedOps = controller.cured || {};
38
38
 
39
- const customActions = generateCustomActions(controller, modelName);
39
+ const customActions = generateCustomActions(controller);
40
40
 
41
41
  const validate = generateValidateMethod(model, modelName);
42
42
  const create = curedOps.create ? generateCreateMethod(model, modelName, modelVar, collection) : '';
@@ -56,7 +56,7 @@ export default function generateMongoNativeController(context: TemplateContext):
56
56
  import { ObjectId, type Filter, type Document } from 'mongodb';
57
57
  import { getCollection } from '../db/mongoClient.js';
58
58
  ${hasEventPublishing ? `import { eventBus } from '../events/eventBus.js';` : ''}
59
- ${customActions.needsAiBehaviors ? `import * as aiBehaviors from '../behaviors/${modelName}Controller.ai.js';` : ''}
59
+ ${customActions.needsAiBehaviors ? `import * as aiBehaviors from '../behaviors/${controllerName}.ai.js';` : ''}
60
60
 
61
61
  const COLLECTION_NAME = '${collection}';
62
62
 
@@ -147,7 +147,7 @@ function generateCreateMethod(model: any, modelName: string, modelVar: string, c
147
147
  const validation = this.validate(data, { operation: 'create' });
148
148
  if (!validation.valid) throw new Error(\`Validation failed: \${validation.errors.join(', ')}\`);
149
149
 
150
- const collection = await getCollection('${collection}');
150
+ const collection = await getCollection(COLLECTION_NAME);
151
151
  const result = await collection.insertOne({ ...data });
152
152
  const ${modelVar} = { _id: result.insertedId, ...data };
153
153
 
@@ -163,7 +163,7 @@ function generateRetrieveMethod(modelName: string, modelVar: string, collection:
163
163
  * Retrieve ${modelName} by id. Returns null when not found.
164
164
  */
165
165
  public async retrieve(id: string): Promise<any | null> {
166
- const collection = await getCollection('${collection}');
166
+ const collection = await getCollection(COLLECTION_NAME);
167
167
  return await collection.findOne(byId(id));
168
168
  }
169
169
 
@@ -171,7 +171,7 @@ function generateRetrieveMethod(modelName: string, modelVar: string, collection:
171
171
  * Retrieve a page of ${modelName}s.
172
172
  */
173
173
  public async retrieveAll(options: { skip?: number; take?: number } = {}): Promise<any[]> {
174
- const collection = await getCollection('${collection}');
174
+ const collection = await getCollection(COLLECTION_NAME);
175
175
  const cursor = collection.find({});
176
176
  if (options.skip) cursor.skip(options.skip);
177
177
  if (options.take) cursor.limit(options.take);
@@ -198,7 +198,7 @@ function generateUpdateMethod(modelName: string, modelVar: string, collection: s
198
198
  updateData[key] = value;
199
199
  }
200
200
 
201
- const collection = await getCollection('${collection}');
201
+ const collection = await getCollection(COLLECTION_NAME);
202
202
  await collection.updateOne(byId(id), { $set: updateData });
203
203
  const ${modelVar} = await collection.findOne(byId(id));
204
204
  if (!${modelVar}) throw new Error('${modelName} not found after update');
@@ -231,7 +231,7 @@ function generateEvolveMethod(model: any, modelName: string, modelVar: string, c
231
231
  * States: ${states.join(' → ') || '(none declared)'}
232
232
  */
233
233
  public async evolve(id: string, data: any): Promise<any> {
234
- const collection = await getCollection('${collection}');
234
+ const collection = await getCollection(COLLECTION_NAME);
235
235
  const current = await collection.findOne(byId(id));
236
236
  if (!current) throw new Error('${modelName} not found');
237
237
 
@@ -264,7 +264,7 @@ function generateDeleteMethod(modelName: string, modelVar: string, collection: s
264
264
  * Delete ${modelName}.
265
265
  */
266
266
  public async delete(id: string): Promise<void> {
267
- const collection = await getCollection('${collection}');
267
+ const collection = await getCollection(COLLECTION_NAME);
268
268
  const ${modelVar} = await collection.findOne(byId(id));
269
269
  await collection.deleteOne(byId(id));
270
270
  if (${modelVar}) {
@@ -279,16 +279,83 @@ interface CustomActionsResult {
279
279
  needsAiBehaviors: boolean;
280
280
  }
281
281
 
282
- function generateCustomActions(controller: any, modelName: string): CustomActionsResult {
282
+ /**
283
+ * Custom actions emit a per-step body using `matchMongoStep`. The same
284
+ * matcher is passed to the AI-behaviors-generator (via realize/index.ts)
285
+ * so both sides accumulate the same `declaredVars` set and produce
286
+ * matching function names + inputs for unmatched steps.
287
+ *
288
+ * Per step:
289
+ * - matched → emit conventional native-driver code inline
290
+ * - unmatched → emit `const stepNResult = await aiBehaviors.<funcName>({...});`
291
+ * (the aiBehaviors function comes from the controller's `*.ai.ts`
292
+ * file, generated by AI-behaviors-generator using the SAME matcher).
293
+ */
294
+ import { matchMongoStep, type MongoStepContext } from './step-conventions.js';
295
+
296
+ function generateCustomActions(controller: any): CustomActionsResult {
283
297
  if (!controller.actions || Object.keys(controller.actions).length === 0) {
284
298
  return { code: '', needsAiBehaviors: false };
285
299
  }
300
+ const CRUD_NAMES = new Set(['create', 'retrieve', 'retrieveAll', 'update', 'evolve', 'delete', 'validate']);
301
+ const modelName = controller.model || (controller.name || '').replace(/Controller$/, '') || 'Model';
302
+ const collectionName = modelName.toLowerCase() + 's';
286
303
  const out: string[] = [];
304
+ let needsAiBehaviors = false;
287
305
  for (const [actionName, action] of Object.entries<any>(controller.actions)) {
288
- const params = generateActionParams(action);
289
- const stepsHeader = (action.steps && action.steps.length > 0)
290
- ? action.steps.map((s: any) => ` * - ${typeof s === 'string' ? s : (s.action || JSON.stringify(s))}`).join('\n')
306
+ if (CRUD_NAMES.has(actionName)) continue;
307
+ const steps: any[] = Array.isArray(action.steps) ? action.steps : [];
308
+ const stepsHeader = steps.length > 0
309
+ ? steps.map((s: any) => ` * - ${typeof s === 'string' ? s : (s.action || JSON.stringify(s))}`).join('\n')
291
310
  : ' * (no spec steps declared)';
311
+
312
+ const declaredVars = new Set<string>();
313
+ const stepBodies: string[] = [];
314
+ let usesArgs = false;
315
+ let actionRefersToAi = false;
316
+ steps.forEach((rawStep: any, i: number) => {
317
+ const stepText = typeof rawStep === 'string' ? rawStep : (rawStep?.step || rawStep?.action);
318
+ if (typeof stepText !== 'string') {
319
+ stepBodies.push(` // Step ${i + 1}: (non-string step ignored)`);
320
+ return;
321
+ }
322
+ const ctx: MongoStepContext = {
323
+ modelName,
324
+ collectionName,
325
+ serviceName: controller.name || 'Controller',
326
+ operationName: actionName,
327
+ stepNum: i + 1,
328
+ parameterNames: Object.keys(action.parameters || {}),
329
+ declaredVars,
330
+ };
331
+ const result = matchMongoStep(stepText, ctx);
332
+ stepBodies.push(result.call);
333
+ if (/\bargs\./.test(result.call)) usesArgs = true;
334
+ if (!result.matched) actionRefersToAi = true;
335
+ });
336
+
337
+ if (actionRefersToAi) needsAiBehaviors = true;
338
+ const argsParam = usesArgs ? 'args: any = {}' : '_args: any = {}';
339
+ let combined = stepBodies.join('\n\n');
340
+ // Drop the `const stepNResult =` declaration when no later step
341
+ // references the result. Strict tsc's noUnusedLocals applies to
342
+ // locals regardless of underscore prefix, so the only safe fix is
343
+ // to omit the assignment entirely. The await still fires.
344
+ const stepResultRe = /const\s+(step\d+Result)\s*=/g;
345
+ let mres: RegExpExecArray | null;
346
+ const declared: string[] = [];
347
+ while ((mres = stepResultRe.exec(combined)) !== null) declared.push(mres[1]);
348
+ for (const name of declared) {
349
+ const refCount = (combined.match(new RegExp(`\\b${name}\\b`, 'g')) || []).length;
350
+ if (refCount <= 1) {
351
+ // Only the declaration itself — drop the binding, keep the await.
352
+ combined = combined.replace(new RegExp(`const\\s+${name}\\s*=\\s*`), '');
353
+ }
354
+ }
355
+ const body = steps.length > 0
356
+ ? combined + `\n return { success: true };`
357
+ : ` throw new Error('${controller.name || 'Controller'}.${actionName} is not implemented');`;
358
+
292
359
  out.push(`
293
360
  /**
294
361
  * ${actionName}
@@ -297,20 +364,10 @@ function generateCustomActions(controller: any, modelName: string): CustomAction
297
364
  * Spec steps:
298
365
  ${stepsHeader}
299
366
  */
300
- public async ${actionName}(${params}): Promise<any> {
301
- return await aiBehaviors.${actionName}({ controller: this, ...args });
367
+ public async ${actionName}(${argsParam}): Promise<any> {
368
+ ${body}
302
369
  }
303
370
  `);
304
371
  }
305
- return { code: out.join('\n'), needsAiBehaviors: true };
306
- }
307
-
308
- function generateActionParams(action: any): string {
309
- if (Array.isArray(action.parameters) && action.parameters.length > 0) {
310
- return 'args: any';
311
- }
312
- if (action.parameters && typeof action.parameters === 'object' && Object.keys(action.parameters).length > 0) {
313
- return 'args: any';
314
- }
315
- return 'args: any = {}';
372
+ return { code: out.join('\n'), needsAiBehaviors };
316
373
  }