@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.
- package/dist/inference/core/specly-converter.d.ts.map +1 -1
- package/dist/inference/core/specly-converter.js +20 -0
- package/dist/inference/core/specly-converter.js.map +1 -1
- package/dist/inference/index.d.ts.map +1 -1
- package/dist/inference/index.js +72 -22
- package/dist/inference/index.js.map +1 -1
- package/dist/inference/logical/generators/controller-generator.d.ts.map +1 -1
- package/dist/inference/logical/generators/controller-generator.js +26 -4
- package/dist/inference/logical/generators/controller-generator.js.map +1 -1
- package/dist/libs/instance-factories/applications/templates/generic/backend-package-json-generator.js +26 -10
- package/dist/libs/instance-factories/controllers/templates/fastify/routes-generator.js +50 -15
- package/dist/libs/instance-factories/controllers/templates/fastify/server-generator.js +27 -7
- package/dist/libs/instance-factories/services/templates/mongodb-native/controller-generator.js +59 -23
- package/dist/libs/instance-factories/services/templates/mongodb-native/step-conventions.js +319 -0
- package/dist/libs/instance-factories/services/templates/prisma/ai-behaviors-generator.js +192 -28
- package/dist/parser/processors/ExecutableProcessor.d.ts.map +1 -1
- package/dist/parser/processors/ExecutableProcessor.js +14 -1
- package/dist/parser/processors/ExecutableProcessor.js.map +1 -1
- package/dist/realize/index.d.ts.map +1 -1
- package/dist/realize/index.js +22 -3
- package/dist/realize/index.js.map +1 -1
- package/libs/instance-factories/applications/templates/generic/backend-package-json-generator.ts +48 -12
- package/libs/instance-factories/controllers/templates/fastify/routes-generator.ts +80 -21
- package/libs/instance-factories/controllers/templates/fastify/server-generator.ts +49 -8
- package/libs/instance-factories/services/templates/mongodb-native/__tests__/controller-generator.test.ts +3 -1
- package/libs/instance-factories/services/templates/mongodb-native/controller-generator.ts +82 -25
- package/libs/instance-factories/services/templates/mongodb-native/step-conventions.ts +423 -0
- package/libs/instance-factories/services/templates/prisma/ai-behaviors-generator.ts +287 -38
- package/package.json +6 -6
package/libs/instance-factories/applications/templates/generic/backend-package-json-generator.ts
CHANGED
|
@@ -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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
${
|
|
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 —
|
|
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
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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}(
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
|
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/${
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
289
|
-
const
|
|
290
|
-
|
|
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}(${
|
|
301
|
-
|
|
367
|
+
public async ${actionName}(${argsParam}): Promise<any> {
|
|
368
|
+
${body}
|
|
302
369
|
}
|
|
303
370
|
`);
|
|
304
371
|
}
|
|
305
|
-
return { code: out.join('\n'), needsAiBehaviors
|
|
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
|
}
|