@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
|
@@ -21,10 +21,14 @@ function generateFastifyRoutes(context) {
|
|
|
21
21
|
for (const [actionName, action] of Object.entries(controller.actions)) {
|
|
22
22
|
const params = action.parameters || {};
|
|
23
23
|
const hasIdParam = Object.keys(params).some((p) => p === "id" || p === `${modelName?.charAt(0).toLowerCase()}${modelName?.slice(1)}Id`);
|
|
24
|
+
const explicitPath = action.path;
|
|
25
|
+
const explicitMethod = action.method;
|
|
26
|
+
const isExternal = !!(explicitPath && /^\/api\//.test(explicitPath));
|
|
24
27
|
endpoints.push({
|
|
25
28
|
operation: actionName,
|
|
26
|
-
method: "POST",
|
|
27
|
-
path: hasIdParam ? `/:id/${actionName}` : `/${actionName}
|
|
29
|
+
method: explicitMethod || "POST",
|
|
30
|
+
path: explicitPath || (hasIdParam ? `/:id/${actionName}` : `/${actionName}`),
|
|
31
|
+
external: isExternal,
|
|
28
32
|
parameters: params,
|
|
29
33
|
description: action.description || `Custom action: ${actionName}`
|
|
30
34
|
});
|
|
@@ -33,9 +37,27 @@ function generateFastifyRoutes(context) {
|
|
|
33
37
|
if (!endpoints || endpoints.length === 0) {
|
|
34
38
|
console.warn(`Warning: Controller ${controllerName} has no endpoints. Generating empty routes file.`);
|
|
35
39
|
}
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
const internalEndpoints = endpoints?.filter((e) => !e.external) || [];
|
|
41
|
+
const externalEndpoints = endpoints?.filter((e) => e.external) || [];
|
|
42
|
+
const internalHandlers = internalEndpoints.map((endpoint) => generateRouteHandler(endpoint, modelName, handlerName, isModelController, implType, controllerName)).join("\n\n");
|
|
43
|
+
const externalHandlers = externalEndpoints.map((endpoint) => generateRouteHandler(endpoint, modelName, handlerName, isModelController, implType, controllerName)).join("\n\n");
|
|
44
|
+
const externalRoutesExport = externalEndpoints.length > 0 ? `
|
|
45
|
+
/**
|
|
46
|
+
* Mount the external (root-prefix) routes for this controller.
|
|
47
|
+
*
|
|
48
|
+
* Called by the generated main.ts at root scope (no prefix) so action.path
|
|
49
|
+
* declarations like '/api/v2/auth/register' land at exactly that URL,
|
|
50
|
+
* bypassing the controller's '/api/<plural>' prefix.
|
|
51
|
+
*/
|
|
52
|
+
export async function registerExternalRoutes(
|
|
53
|
+
fastify: FastifyInstance,
|
|
54
|
+
options: any
|
|
55
|
+
) {
|
|
56
|
+
const handler = ${isModelController ? "options.controllers" : "options.services"}.${handlerName};
|
|
57
|
+
|
|
58
|
+
${externalHandlers.split("\n").map((line) => " " + line).join("\n")}
|
|
59
|
+
}
|
|
60
|
+
` : "";
|
|
39
61
|
return `${imports}
|
|
40
62
|
|
|
41
63
|
/**
|
|
@@ -51,9 +73,9 @@ export default async function ${routeName.replace("Controller", "")}Routes(
|
|
|
51
73
|
) {
|
|
52
74
|
const handler = ${isModelController ? "options.controllers" : "options.services"}.${handlerName};
|
|
53
75
|
|
|
54
|
-
${
|
|
76
|
+
${internalHandlers.split("\n").map((line) => " " + line).join("\n")}
|
|
55
77
|
}
|
|
56
|
-
`;
|
|
78
|
+
${externalRoutesExport}`;
|
|
57
79
|
}
|
|
58
80
|
function generateImports(controller, modelName, handlerName, isModelController, implType) {
|
|
59
81
|
const imports = [
|
|
@@ -226,36 +248,49 @@ function generateHandlerBody(operation, modelName, handlerName, isModelControlle
|
|
|
226
248
|
});
|
|
227
249
|
}`;
|
|
228
250
|
default: {
|
|
251
|
+
const path = endpoint?.path || "";
|
|
252
|
+
const urlParamNames = /* @__PURE__ */ new Set();
|
|
253
|
+
const urlParamRegex = /:([A-Za-z_][\w]*)/g;
|
|
254
|
+
let urlMatch;
|
|
255
|
+
while ((urlMatch = urlParamRegex.exec(path)) !== null) {
|
|
256
|
+
urlParamNames.add(urlMatch[1]);
|
|
257
|
+
}
|
|
229
258
|
const params = endpoint?.parameters || {};
|
|
230
|
-
const paramNames = Object.keys(params);
|
|
231
|
-
const callArgs = paramNames.length > 0 ? paramNames.map((p) => `body.${p}`).join(", ") : "body";
|
|
232
259
|
const validations = [];
|
|
233
260
|
for (const [pName, pDef] of Object.entries(params)) {
|
|
261
|
+
if (urlParamNames.has(pName)) continue;
|
|
234
262
|
const required = typeof pDef === "string" ? pDef.includes("required") : pDef?.required;
|
|
235
263
|
const typeStr = typeof pDef === "string" ? pDef.split(" ")[0] : pDef?.type || "String";
|
|
236
264
|
if (required) {
|
|
237
|
-
validations.push(` if (
|
|
265
|
+
validations.push(` if (args.${pName} === undefined || args.${pName} === null) errors.push({ field: '${pName}', message: '${pName} is required' });`);
|
|
238
266
|
}
|
|
239
267
|
if (typeStr === "UUID" || typeStr === "String" || typeStr === "Email") {
|
|
240
|
-
validations.push(` if (
|
|
268
|
+
validations.push(` if (args.${pName} !== undefined && typeof args.${pName} !== 'string') errors.push({ field: '${pName}', message: '${pName} must be a string' });`);
|
|
241
269
|
} else if (typeStr === "Integer" || typeStr === "Number" || typeStr === "Float") {
|
|
242
|
-
validations.push(` if (
|
|
270
|
+
validations.push(` if (args.${pName} !== undefined && typeof args.${pName} !== 'number') errors.push({ field: '${pName}', message: '${pName} must be a number' });`);
|
|
243
271
|
} else if (typeStr === "Boolean") {
|
|
244
|
-
validations.push(` if (
|
|
272
|
+
validations.push(` if (args.${pName} !== undefined && typeof args.${pName} !== 'boolean') errors.push({ field: '${pName}', message: '${pName} must be a boolean' });`);
|
|
245
273
|
}
|
|
246
274
|
}
|
|
247
275
|
const validationBlock = validations.length > 0 ? ` const errors: Array<{ field: string; message: string }> = [];
|
|
248
276
|
${validations.join("\n")}
|
|
249
277
|
if (errors.length > 0) return reply.status(400).send({ error: 'Validation failed', details: errors });` : "";
|
|
250
278
|
return `try {
|
|
279
|
+
const params = (request.params || {}) as Record<string, any>;
|
|
251
280
|
const body = (request.body || {}) as Record<string, any>;
|
|
281
|
+
const args = { ...params, ...body };
|
|
252
282
|
${validationBlock}
|
|
253
|
-
const result = await handler.${operation}(
|
|
283
|
+
const result = await handler.${operation}(args);
|
|
254
284
|
return reply.send(result || { success: true });
|
|
255
285
|
} catch (error) {
|
|
286
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
287
|
+
// 501 for TODO-stubbed action bodies (#43F); 400 for real failures.
|
|
288
|
+
if (msg.includes('is not implemented')) {
|
|
289
|
+
return reply.status(501).send({ error: 'Not Implemented', message: msg });
|
|
290
|
+
}
|
|
256
291
|
return reply.status(400).send({
|
|
257
292
|
error: 'Failed to execute ${operation}',
|
|
258
|
-
message:
|
|
293
|
+
message: msg
|
|
259
294
|
});
|
|
260
295
|
}`;
|
|
261
296
|
}
|
|
@@ -1,15 +1,31 @@
|
|
|
1
1
|
import { normalizeSpec, deriveBasePath } from "@specverse/types/spec-rules";
|
|
2
|
+
function resolveOrmName(manifest) {
|
|
3
|
+
if (!manifest) return "PrismaORM";
|
|
4
|
+
const inner = manifest.manifests ? Object.values(manifest.manifests)[0] : manifest;
|
|
5
|
+
if (!inner) return "PrismaORM";
|
|
6
|
+
const caps = Array.isArray(inner.capabilityMappings) ? inner.capabilityMappings : [];
|
|
7
|
+
const ormCap = caps.find((m) => m?.capability === "orm.client") || caps.find((m) => m?.capability === "orm.schema");
|
|
8
|
+
if (ormCap) return ormCap.implementation || ormCap.instanceFactory || "PrismaORM";
|
|
9
|
+
return inner.defaultMappings?.orm || "PrismaORM";
|
|
10
|
+
}
|
|
2
11
|
function generateFastifyServer(context) {
|
|
3
|
-
const { spec, models } = context;
|
|
12
|
+
const { spec, models, manifest } = context;
|
|
13
|
+
const orm = resolveOrmName(manifest);
|
|
14
|
+
const isMongoNative = orm === "MongoDBNativeDriver";
|
|
4
15
|
const allModels = models || (spec?.models ? Object.values(spec.models) : []);
|
|
5
16
|
const modelNames = allModels.map((m) => m.name).filter(Boolean);
|
|
6
17
|
const routeImports = modelNames.map(
|
|
7
|
-
(name) => `import ${name}Routes from './routes/${name}Controller.js';`
|
|
18
|
+
(name) => `import ${name}Routes, * as ${name}RoutesNS from './routes/${name}Controller.js';`
|
|
8
19
|
).join("\n");
|
|
9
20
|
const routeRegistrations = modelNames.map((name) => {
|
|
10
21
|
const path = deriveBasePath(name);
|
|
11
22
|
return ` await fastify.register(${name}Routes, { prefix: '${path}', controllers: { ${name}Controller: new (await import('./controllers/${name}Controller.js')).${name}Controller() } });`;
|
|
12
23
|
}).join("\n");
|
|
24
|
+
const externalRouteRegistrations = modelNames.map((name) => {
|
|
25
|
+
return ` if (typeof (${name}RoutesNS as any).registerExternalRoutes === 'function') {
|
|
26
|
+
await (${name}RoutesNS as any).registerExternalRoutes(fastify, { controllers: { ${name}Controller: new (await import('./controllers/${name}Controller.js')).${name}Controller() } });
|
|
27
|
+
}`;
|
|
28
|
+
}).join("\n");
|
|
13
29
|
const specEvents = spec.events ? Object.keys(spec.events) : [];
|
|
14
30
|
const hasEvents = specEvents.length > 0 || modelNames.length > 0;
|
|
15
31
|
const servicesList = spec.services ? Object.keys(spec.services) : [];
|
|
@@ -39,7 +55,7 @@ function generateFastifyServer(context) {
|
|
|
39
55
|
// workspace before running the script.
|
|
40
56
|
import { config as loadEnv } from 'dotenv';
|
|
41
57
|
import { existsSync } from 'fs';
|
|
42
|
-
import {
|
|
58
|
+
import { dirname, join } from 'path';
|
|
43
59
|
import { fileURLToPath } from 'url';
|
|
44
60
|
{
|
|
45
61
|
let dir = dirname(fileURLToPath(import.meta.url));
|
|
@@ -52,12 +68,13 @@ import { fileURLToPath } from 'url';
|
|
|
52
68
|
|
|
53
69
|
import Fastify from 'fastify';
|
|
54
70
|
import cors from '@fastify/cors';
|
|
55
|
-
import { PrismaClient } from '@prisma/client'
|
|
71
|
+
${isMongoNative ? `import { disconnect as disconnectMongo } from './db/mongoClient.js';` : `import { PrismaClient } from '@prisma/client';`}
|
|
56
72
|
${hasEvents ? `import { eventBus } from './events/eventBus.js';
|
|
57
73
|
import { registerWebSocketBridge } from './events/websocket-bridge.js';` : ""}
|
|
58
74
|
|
|
59
|
-
|
|
60
|
-
|
|
75
|
+
${isMongoNative ? `// MongoDB native driver client lives in db/mongoClient.ts; nothing
|
|
76
|
+
// to initialize at module scope (lazy-connect on first getCollection).` : `// Initialize Prisma
|
|
77
|
+
export const prisma = new PrismaClient();`}
|
|
61
78
|
|
|
62
79
|
// Initialize Fastify
|
|
63
80
|
const fastify = Fastify({
|
|
@@ -99,6 +116,9 @@ ${serviceRouteImports}` : ""}
|
|
|
99
116
|
async function registerRoutes() {
|
|
100
117
|
${routeRegistrations}
|
|
101
118
|
${serviceRouteRegistrations ? "\n // Service operation routes (RPC-style under /api/services/{ServiceName})\n" + serviceRouteRegistrations : ""}
|
|
119
|
+
|
|
120
|
+
// External-prefix actions (action.path declarations like '/api/v2/auth/register')
|
|
121
|
+
${externalRouteRegistrations}
|
|
102
122
|
}
|
|
103
123
|
|
|
104
124
|
// Start server
|
|
@@ -127,7 +147,7 @@ ${hasEvents ? ` console.log(\`WebSocket: ws://localhost:\${port}/ws\`);
|
|
|
127
147
|
try {
|
|
128
148
|
fastify.log.info(\`Received \${signal}, closing server...\`);
|
|
129
149
|
await fastify.close();
|
|
130
|
-
try { await (prisma as any).$disconnect?.(); } catch { /* ignore */ }
|
|
150
|
+
try { ${isMongoNative ? "await disconnectMongo();" : "await (prisma as any).$disconnect?.();"} } catch { /* ignore */ }
|
|
131
151
|
process.exit(0);
|
|
132
152
|
} catch (err) {
|
|
133
153
|
fastify.log.error({ err }, 'Error during shutdown');
|
package/dist/libs/instance-factories/services/templates/mongodb-native/controller-generator.js
CHANGED
|
@@ -8,7 +8,7 @@ function generateMongoNativeController(context) {
|
|
|
8
8
|
const modelVar = lowerFirst(modelName);
|
|
9
9
|
const collection = collectionName(model);
|
|
10
10
|
const curedOps = controller.cured || {};
|
|
11
|
-
const customActions = generateCustomActions(controller
|
|
11
|
+
const customActions = generateCustomActions(controller);
|
|
12
12
|
const validate = generateValidateMethod(model, modelName);
|
|
13
13
|
const create = curedOps.create ? generateCreateMethod(model, modelName, modelVar, collection) : "";
|
|
14
14
|
const retrieve = curedOps.retrieve ? generateRetrieveMethod(modelName, modelVar, collection) : "";
|
|
@@ -24,7 +24,7 @@ function generateMongoNativeController(context) {
|
|
|
24
24
|
import { ObjectId, type Filter, type Document } from 'mongodb';
|
|
25
25
|
import { getCollection } from '../db/mongoClient.js';
|
|
26
26
|
${hasEventPublishing ? `import { eventBus } from '../events/eventBus.js';` : ""}
|
|
27
|
-
${customActions.needsAiBehaviors ? `import * as aiBehaviors from '../behaviors/${
|
|
27
|
+
${customActions.needsAiBehaviors ? `import * as aiBehaviors from '../behaviors/${controllerName}.ai.js';` : ""}
|
|
28
28
|
|
|
29
29
|
const COLLECTION_NAME = '${collection}';
|
|
30
30
|
|
|
@@ -107,7 +107,7 @@ function generateCreateMethod(model, modelName, modelVar, collection) {
|
|
|
107
107
|
const validation = this.validate(data, { operation: 'create' });
|
|
108
108
|
if (!validation.valid) throw new Error(\`Validation failed: \${validation.errors.join(', ')}\`);
|
|
109
109
|
|
|
110
|
-
const collection = await getCollection(
|
|
110
|
+
const collection = await getCollection(COLLECTION_NAME);
|
|
111
111
|
const result = await collection.insertOne({ ...data });
|
|
112
112
|
const ${modelVar} = { _id: result.insertedId, ...data };
|
|
113
113
|
|
|
@@ -122,7 +122,7 @@ function generateRetrieveMethod(modelName, modelVar, collection) {
|
|
|
122
122
|
* Retrieve ${modelName} by id. Returns null when not found.
|
|
123
123
|
*/
|
|
124
124
|
public async retrieve(id: string): Promise<any | null> {
|
|
125
|
-
const collection = await getCollection(
|
|
125
|
+
const collection = await getCollection(COLLECTION_NAME);
|
|
126
126
|
return await collection.findOne(byId(id));
|
|
127
127
|
}
|
|
128
128
|
|
|
@@ -130,7 +130,7 @@ function generateRetrieveMethod(modelName, modelVar, collection) {
|
|
|
130
130
|
* Retrieve a page of ${modelName}s.
|
|
131
131
|
*/
|
|
132
132
|
public async retrieveAll(options: { skip?: number; take?: number } = {}): Promise<any[]> {
|
|
133
|
-
const collection = await getCollection(
|
|
133
|
+
const collection = await getCollection(COLLECTION_NAME);
|
|
134
134
|
const cursor = collection.find({});
|
|
135
135
|
if (options.skip) cursor.skip(options.skip);
|
|
136
136
|
if (options.take) cursor.limit(options.take);
|
|
@@ -156,7 +156,7 @@ function generateUpdateMethod(modelName, modelVar, collection) {
|
|
|
156
156
|
updateData[key] = value;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
const collection = await getCollection(
|
|
159
|
+
const collection = await getCollection(COLLECTION_NAME);
|
|
160
160
|
await collection.updateOne(byId(id), { $set: updateData });
|
|
161
161
|
const ${modelVar} = await collection.findOne(byId(id));
|
|
162
162
|
if (!${modelVar}) throw new Error('${modelName} not found after update');
|
|
@@ -181,7 +181,7 @@ function generateEvolveMethod(model, modelName, modelVar, collection) {
|
|
|
181
181
|
* States: ${states.join(" \u2192 ") || "(none declared)"}
|
|
182
182
|
*/
|
|
183
183
|
public async evolve(id: string, data: any): Promise<any> {
|
|
184
|
-
const collection = await getCollection(
|
|
184
|
+
const collection = await getCollection(COLLECTION_NAME);
|
|
185
185
|
const current = await collection.findOne(byId(id));
|
|
186
186
|
if (!current) throw new Error('${modelName} not found');
|
|
187
187
|
|
|
@@ -213,7 +213,7 @@ function generateDeleteMethod(modelName, modelVar, collection) {
|
|
|
213
213
|
* Delete ${modelName}.
|
|
214
214
|
*/
|
|
215
215
|
public async delete(id: string): Promise<void> {
|
|
216
|
-
const collection = await getCollection(
|
|
216
|
+
const collection = await getCollection(COLLECTION_NAME);
|
|
217
217
|
const ${modelVar} = await collection.findOne(byId(id));
|
|
218
218
|
await collection.deleteOne(byId(id));
|
|
219
219
|
if (${modelVar}) {
|
|
@@ -222,14 +222,59 @@ function generateDeleteMethod(modelName, modelVar, collection) {
|
|
|
222
222
|
}
|
|
223
223
|
`;
|
|
224
224
|
}
|
|
225
|
-
|
|
225
|
+
import { matchMongoStep } from "./step-conventions.js";
|
|
226
|
+
function generateCustomActions(controller) {
|
|
226
227
|
if (!controller.actions || Object.keys(controller.actions).length === 0) {
|
|
227
228
|
return { code: "", needsAiBehaviors: false };
|
|
228
229
|
}
|
|
230
|
+
const CRUD_NAMES = /* @__PURE__ */ new Set(["create", "retrieve", "retrieveAll", "update", "evolve", "delete", "validate"]);
|
|
231
|
+
const modelName = controller.model || (controller.name || "").replace(/Controller$/, "") || "Model";
|
|
232
|
+
const collectionName2 = modelName.toLowerCase() + "s";
|
|
229
233
|
const out = [];
|
|
234
|
+
let needsAiBehaviors = false;
|
|
230
235
|
for (const [actionName, action] of Object.entries(controller.actions)) {
|
|
231
|
-
|
|
232
|
-
const
|
|
236
|
+
if (CRUD_NAMES.has(actionName)) continue;
|
|
237
|
+
const steps = Array.isArray(action.steps) ? action.steps : [];
|
|
238
|
+
const stepsHeader = steps.length > 0 ? steps.map((s) => ` * - ${typeof s === "string" ? s : s.action || JSON.stringify(s)}`).join("\n") : " * (no spec steps declared)";
|
|
239
|
+
const declaredVars = /* @__PURE__ */ new Set();
|
|
240
|
+
const stepBodies = [];
|
|
241
|
+
let usesArgs = false;
|
|
242
|
+
let actionRefersToAi = false;
|
|
243
|
+
steps.forEach((rawStep, i) => {
|
|
244
|
+
const stepText = typeof rawStep === "string" ? rawStep : rawStep?.step || rawStep?.action;
|
|
245
|
+
if (typeof stepText !== "string") {
|
|
246
|
+
stepBodies.push(` // Step ${i + 1}: (non-string step ignored)`);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const ctx = {
|
|
250
|
+
modelName,
|
|
251
|
+
collectionName: collectionName2,
|
|
252
|
+
serviceName: controller.name || "Controller",
|
|
253
|
+
operationName: actionName,
|
|
254
|
+
stepNum: i + 1,
|
|
255
|
+
parameterNames: Object.keys(action.parameters || {}),
|
|
256
|
+
declaredVars
|
|
257
|
+
};
|
|
258
|
+
const result = matchMongoStep(stepText, ctx);
|
|
259
|
+
stepBodies.push(result.call);
|
|
260
|
+
if (/\bargs\./.test(result.call)) usesArgs = true;
|
|
261
|
+
if (!result.matched) actionRefersToAi = true;
|
|
262
|
+
});
|
|
263
|
+
if (actionRefersToAi) needsAiBehaviors = true;
|
|
264
|
+
const argsParam = usesArgs ? "args: any = {}" : "_args: any = {}";
|
|
265
|
+
let combined = stepBodies.join("\n\n");
|
|
266
|
+
const stepResultRe = /const\s+(step\d+Result)\s*=/g;
|
|
267
|
+
let mres;
|
|
268
|
+
const declared = [];
|
|
269
|
+
while ((mres = stepResultRe.exec(combined)) !== null) declared.push(mres[1]);
|
|
270
|
+
for (const name of declared) {
|
|
271
|
+
const refCount = (combined.match(new RegExp(`\\b${name}\\b`, "g")) || []).length;
|
|
272
|
+
if (refCount <= 1) {
|
|
273
|
+
combined = combined.replace(new RegExp(`const\\s+${name}\\s*=\\s*`), "");
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
const body = steps.length > 0 ? combined + `
|
|
277
|
+
return { success: true };` : ` throw new Error('${controller.name || "Controller"}.${actionName} is not implemented');`;
|
|
233
278
|
out.push(`
|
|
234
279
|
/**
|
|
235
280
|
* ${actionName}
|
|
@@ -238,21 +283,12 @@ function generateCustomActions(controller, modelName) {
|
|
|
238
283
|
* Spec steps:
|
|
239
284
|
${stepsHeader}
|
|
240
285
|
*/
|
|
241
|
-
public async ${actionName}(${
|
|
242
|
-
|
|
286
|
+
public async ${actionName}(${argsParam}): Promise<any> {
|
|
287
|
+
${body}
|
|
243
288
|
}
|
|
244
289
|
`);
|
|
245
290
|
}
|
|
246
|
-
return { code: out.join("\n"), needsAiBehaviors
|
|
247
|
-
}
|
|
248
|
-
function generateActionParams(action) {
|
|
249
|
-
if (Array.isArray(action.parameters) && action.parameters.length > 0) {
|
|
250
|
-
return "args: any";
|
|
251
|
-
}
|
|
252
|
-
if (action.parameters && typeof action.parameters === "object" && Object.keys(action.parameters).length > 0) {
|
|
253
|
-
return "args: any";
|
|
254
|
-
}
|
|
255
|
-
return "args: any = {}";
|
|
291
|
+
return { code: out.join("\n"), needsAiBehaviors };
|
|
256
292
|
}
|
|
257
293
|
export {
|
|
258
294
|
generateMongoNativeController as default
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
function toVar(name) {
|
|
2
|
+
return name.charAt(0).toLowerCase() + name.slice(1);
|
|
3
|
+
}
|
|
4
|
+
function toCollection(modelName) {
|
|
5
|
+
return modelName.toLowerCase() + "s";
|
|
6
|
+
}
|
|
7
|
+
function toMethod(words) {
|
|
8
|
+
const cleaned = words.trim().replace(/[^A-Za-z0-9\s]+/g, " ");
|
|
9
|
+
const camel = cleaned.replace(/\s+(.)/g, (_, c) => c.toUpperCase()).replace(/^\w/, (c) => c.toLowerCase());
|
|
10
|
+
const safe = camel.replace(/[^A-Za-z0-9_$]/g, "");
|
|
11
|
+
return safe || "unnamedStep";
|
|
12
|
+
}
|
|
13
|
+
function resolveValue(rawValue, ctx) {
|
|
14
|
+
const value = rawValue.trim().replace(/^['"]|['"]$/g, "");
|
|
15
|
+
if (/^(current\s*time|now|timestamp)$/i.test(value)) return "new Date().toISOString()";
|
|
16
|
+
if (value === "true" || value === "false") return value;
|
|
17
|
+
if (/^-?\d+(\.\d+)?$/.test(value)) return value;
|
|
18
|
+
const declared = ctx.declaredVars || /* @__PURE__ */ new Set();
|
|
19
|
+
const params = ctx.parameterNames || [];
|
|
20
|
+
const rootMatch = value.match(/^([a-zA-Z_][a-zA-Z0-9_]*)(\.[a-zA-Z0-9_.]+)?$/);
|
|
21
|
+
if (rootMatch) {
|
|
22
|
+
const root = rootMatch[1];
|
|
23
|
+
if (declared.has(root) || params.includes(root)) return value;
|
|
24
|
+
if (params.length > 0) return `args.${value}`;
|
|
25
|
+
}
|
|
26
|
+
if (/\s/.test(value)) return `/* TODO: resolve "${value}" */ null`;
|
|
27
|
+
return `'${value.replace(/'/g, "\\'")}'`;
|
|
28
|
+
}
|
|
29
|
+
const MONGO_STEP_CONVENTIONS = [
|
|
30
|
+
// --- Find / Lookup by single field ---
|
|
31
|
+
// Matches: "Look up X by Y", "Find X by Y", "Find X by Y or fail with 404"
|
|
32
|
+
{
|
|
33
|
+
name: "find-by-field",
|
|
34
|
+
pattern: /^(?:look\s+up|find|fetch|get)\s+(\w+)\s+by\s+(\w+)(?:\s+or\s+fail.*)?$/i,
|
|
35
|
+
generateCall: (m, ctx) => {
|
|
36
|
+
const model = m[1];
|
|
37
|
+
const field = m[2];
|
|
38
|
+
const modelVar = toVar(model);
|
|
39
|
+
const collection = toCollection(model);
|
|
40
|
+
const params = ctx.parameterNames || [];
|
|
41
|
+
const declared = ctx.declaredVars || /* @__PURE__ */ new Set();
|
|
42
|
+
const idVal = field === "id" ? params.includes(`${modelVar}Id`) ? `args.${modelVar}Id` : "args.id" : params.includes(field) ? `args.${field}` : `args.${field}`;
|
|
43
|
+
if (declared.has(modelVar)) {
|
|
44
|
+
return ` // Step ${ctx.stepNum}: Find ${model} by ${field} (already loaded)`;
|
|
45
|
+
}
|
|
46
|
+
declared.add(modelVar);
|
|
47
|
+
const failOnMissing = /or\s+fail/i.test(m[0]);
|
|
48
|
+
return ` // Step ${ctx.stepNum}: Find ${model} by ${field}
|
|
49
|
+
const ${modelVar}_collection = await getCollection('${collection}');
|
|
50
|
+
const ${modelVar} = await ${modelVar}_collection.findOne({ ${field}: ${idVal} });${failOnMissing ? `
|
|
51
|
+
if (!${modelVar}) throw new Error('${model} not found');` : ""}`;
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
// --- Find by composite (two fields) ---
|
|
55
|
+
// Matches: "Look up X by Y and Z", "Find X by Y and Z"
|
|
56
|
+
{
|
|
57
|
+
name: "find-by-composite",
|
|
58
|
+
pattern: /^(?:look\s+up|find|fetch|get)(?:\s+existing)?\s+(\w+)\s+by\s+(\w+)\s+and\s+(\w+)$/i,
|
|
59
|
+
generateCall: (m, ctx) => {
|
|
60
|
+
const model = m[1];
|
|
61
|
+
const f1 = m[2];
|
|
62
|
+
const f2 = m[3];
|
|
63
|
+
const modelVar = toVar(model);
|
|
64
|
+
const collection = toCollection(model);
|
|
65
|
+
const declared = ctx.declaredVars || /* @__PURE__ */ new Set();
|
|
66
|
+
if (declared.has(modelVar)) {
|
|
67
|
+
return ` // Step ${ctx.stepNum}: Find ${model} by ${f1} and ${f2} (already loaded)`;
|
|
68
|
+
}
|
|
69
|
+
declared.add(modelVar);
|
|
70
|
+
return ` // Step ${ctx.stepNum}: Find ${model} by ${f1} and ${f2}
|
|
71
|
+
const ${modelVar}_collection = await getCollection('${collection}');
|
|
72
|
+
const ${modelVar} = await ${modelVar}_collection.findOne({ ${f1}: args.${f1}, ${f2}: args.${f2} });`;
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
// --- Create model record ---
|
|
76
|
+
// Matches: "Create X", "Create new X with ..."
|
|
77
|
+
{
|
|
78
|
+
name: "create",
|
|
79
|
+
pattern: /^create\s+(?:new\s+)?(\w+)(?:\s+(?:with\s+)?(.+))?/i,
|
|
80
|
+
generateCall: (m, ctx) => {
|
|
81
|
+
const model = m[1];
|
|
82
|
+
const modelVar = toVar(model);
|
|
83
|
+
const collection = toCollection(model);
|
|
84
|
+
const params = ctx.parameterNames || [];
|
|
85
|
+
const declared = ctx.declaredVars || /* @__PURE__ */ new Set();
|
|
86
|
+
declared.add(modelVar);
|
|
87
|
+
const dataExpr = params.length > 0 ? `{ ${params.join(", ")} }` : "args";
|
|
88
|
+
return ` // Step ${ctx.stepNum}: Create ${model}
|
|
89
|
+
const ${modelVar}_collection = await getCollection('${collection}');
|
|
90
|
+
const ${modelVar}_inserted = await ${modelVar}_collection.insertOne(${dataExpr});
|
|
91
|
+
const ${modelVar} = { _id: ${modelVar}_inserted.insertedId, ...${dataExpr} };`;
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
// --- Update specific field on previously-loaded model ---
|
|
95
|
+
// Matches: "Update X field to value". Only fires when X has been
|
|
96
|
+
// declared by a prior matched find — otherwise the model var doesn't
|
|
97
|
+
// exist in scope and tsc fails. Falling through to the unmatched path
|
|
98
|
+
// lets the AI layer handle it.
|
|
99
|
+
{
|
|
100
|
+
name: "update-field",
|
|
101
|
+
pattern: /^update\s+(\w+)\s+(\w+)\s+to\s+(.+)/i,
|
|
102
|
+
generateCall: (m, ctx) => {
|
|
103
|
+
const model = m[1];
|
|
104
|
+
const field = m[2];
|
|
105
|
+
const rawValue = m[3];
|
|
106
|
+
const modelVar = toVar(model);
|
|
107
|
+
if (!ctx.declaredVars?.has(modelVar)) return "";
|
|
108
|
+
const collection = toCollection(model);
|
|
109
|
+
const val = resolveValue(rawValue, ctx);
|
|
110
|
+
return ` // Step ${ctx.stepNum}: Update ${model}.${field} to ${rawValue.trim()}
|
|
111
|
+
{
|
|
112
|
+
const ${modelVar}_collection = await getCollection('${collection}');
|
|
113
|
+
await ${modelVar}_collection.updateOne({ _id: ${modelVar}._id }, { $set: { ${field}: ${val} } });
|
|
114
|
+
}`;
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
// --- Update field timestamp (e.g. "Update device lastLoginAt timestamp") ---
|
|
118
|
+
{
|
|
119
|
+
name: "update-field-timestamp",
|
|
120
|
+
pattern: /^update\s+(\w+)\s+(\w+)\s+timestamp$/i,
|
|
121
|
+
generateCall: (m, ctx) => {
|
|
122
|
+
const model = m[1];
|
|
123
|
+
const field = m[2];
|
|
124
|
+
const modelVar = toVar(model);
|
|
125
|
+
if (!ctx.declaredVars?.has(modelVar)) return "";
|
|
126
|
+
const collection = toCollection(model);
|
|
127
|
+
return ` // Step ${ctx.stepNum}: Update ${model}.${field} timestamp
|
|
128
|
+
{
|
|
129
|
+
const ${modelVar}_collection = await getCollection('${collection}');
|
|
130
|
+
await ${modelVar}_collection.updateOne({ _id: ${modelVar}._id }, { $set: { ${field}: new Date().toISOString() } });
|
|
131
|
+
}`;
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
// --- Generic "Update X" (writes args back to record) ---
|
|
135
|
+
{
|
|
136
|
+
name: "update",
|
|
137
|
+
pattern: /^update\s+(\w+)(?:\s+(.+))?$/i,
|
|
138
|
+
generateCall: (m, ctx) => {
|
|
139
|
+
const model = m[1];
|
|
140
|
+
const modelVar = toVar(model);
|
|
141
|
+
if (!ctx.declaredVars?.has(modelVar)) return "";
|
|
142
|
+
const collection = toCollection(model);
|
|
143
|
+
return ` // Step ${ctx.stepNum}: Update ${model}
|
|
144
|
+
{
|
|
145
|
+
const ${modelVar}_collection = await getCollection('${collection}');
|
|
146
|
+
await ${modelVar}_collection.updateOne({ _id: ${modelVar}._id }, { $set: args });
|
|
147
|
+
}`;
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
// --- Delete model record ---
|
|
151
|
+
{
|
|
152
|
+
name: "delete",
|
|
153
|
+
pattern: /^delete\s+(\w+)/i,
|
|
154
|
+
generateCall: (m, ctx) => {
|
|
155
|
+
const model = m[1];
|
|
156
|
+
const modelVar = toVar(model);
|
|
157
|
+
if (!ctx.declaredVars?.has(modelVar)) return "";
|
|
158
|
+
const collection = toCollection(model);
|
|
159
|
+
return ` // Step ${ctx.stepNum}: Delete ${model}
|
|
160
|
+
{
|
|
161
|
+
const ${modelVar}_collection = await getCollection('${collection}');
|
|
162
|
+
await ${modelVar}_collection.deleteOne({ _id: ${modelVar}._id });
|
|
163
|
+
}`;
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
// --- Transition to lifecycle state ---
|
|
167
|
+
// Matches: "Transition X to state"
|
|
168
|
+
{
|
|
169
|
+
name: "transition",
|
|
170
|
+
pattern: /^transition\s+(\w+)\s+to\s+(\w+)/i,
|
|
171
|
+
generateCall: (m, ctx) => {
|
|
172
|
+
const model = m[1];
|
|
173
|
+
const state = m[2];
|
|
174
|
+
const modelVar = toVar(model);
|
|
175
|
+
if (!ctx.declaredVars?.has(modelVar)) return "";
|
|
176
|
+
const collection = toCollection(model);
|
|
177
|
+
return ` // Step ${ctx.stepNum}: Transition ${model} to ${state}
|
|
178
|
+
if ((${modelVar} as any).status === '${state}') throw new Error('${model} is already ${state}');
|
|
179
|
+
{
|
|
180
|
+
const ${modelVar}_collection = await getCollection('${collection}');
|
|
181
|
+
await ${modelVar}_collection.updateOne({ _id: ${modelVar}._id }, { $set: { status: '${state}' } });
|
|
182
|
+
}`;
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
// --- Set field to value (on the controller's primary model) ---
|
|
186
|
+
{
|
|
187
|
+
name: "set",
|
|
188
|
+
pattern: /^set\s+(\w+)\s+to\s+(.+)/i,
|
|
189
|
+
generateCall: (m, ctx) => {
|
|
190
|
+
const field = m[1];
|
|
191
|
+
const rawValue = m[2];
|
|
192
|
+
const modelVar = toVar(ctx.modelName);
|
|
193
|
+
const val = resolveValue(rawValue, ctx);
|
|
194
|
+
return ` // Step ${ctx.stepNum}: Set ${field} to ${rawValue.trim()}
|
|
195
|
+
{
|
|
196
|
+
const ${modelVar}_collection = await getCollection(COLLECTION_NAME);
|
|
197
|
+
await ${modelVar}_collection.updateOne({ _id: ${modelVar}._id }, { $set: { ${field}: ${val} } });
|
|
198
|
+
}`;
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
// --- Increment / Decrement ---
|
|
202
|
+
{
|
|
203
|
+
name: "increment",
|
|
204
|
+
pattern: /^increment\s+(\w+)\s+by\s+(\w+)/i,
|
|
205
|
+
generateCall: (m, ctx) => {
|
|
206
|
+
const field = m[1];
|
|
207
|
+
const amount = m[2];
|
|
208
|
+
const modelVar = toVar(ctx.modelName);
|
|
209
|
+
return ` // Step ${ctx.stepNum}: Increment ${field} by ${amount}
|
|
210
|
+
{
|
|
211
|
+
const ${modelVar}_collection = await getCollection(COLLECTION_NAME);
|
|
212
|
+
await ${modelVar}_collection.updateOne({ _id: ${modelVar}._id }, { $inc: { ${field}: ${amount} } });
|
|
213
|
+
}`;
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
name: "decrement",
|
|
218
|
+
pattern: /^decrement\s+(\w+)\s+by\s+(\w+)/i,
|
|
219
|
+
generateCall: (m, ctx) => {
|
|
220
|
+
const field = m[1];
|
|
221
|
+
const amount = m[2];
|
|
222
|
+
const modelVar = toVar(ctx.modelName);
|
|
223
|
+
return ` // Step ${ctx.stepNum}: Decrement ${field} by ${amount}
|
|
224
|
+
{
|
|
225
|
+
const ${modelVar}_collection = await getCollection(COLLECTION_NAME);
|
|
226
|
+
await ${modelVar}_collection.updateOne({ _id: ${modelVar}._id }, { $inc: { ${field}: -${amount} } });
|
|
227
|
+
}`;
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
// --- Send/Emit/Publish event ---
|
|
231
|
+
// Emits an eventBus.publish call. The payload references the controller's
|
|
232
|
+
// primary model variable IF it was declared by a prior matched step;
|
|
233
|
+
// otherwise the payload is just operation + timestamp (the event still
|
|
234
|
+
// fires, just without a record-level id).
|
|
235
|
+
{
|
|
236
|
+
name: "send-event",
|
|
237
|
+
pattern: /^(?:send|emit|publish)\s+(\w+)\s+event/i,
|
|
238
|
+
generateCall: (m, ctx) => {
|
|
239
|
+
const event = m[1];
|
|
240
|
+
const modelVar = toVar(ctx.modelName);
|
|
241
|
+
const hasModelVar = ctx.declaredVars?.has(modelVar);
|
|
242
|
+
const payload = hasModelVar ? `{ ${modelVar}Id: (${modelVar} as any)?._id, operation: '${ctx.operationName}', timestamp: new Date().toISOString() }` : `{ operation: '${ctx.operationName}', timestamp: new Date().toISOString() }`;
|
|
243
|
+
return ` // Step ${ctx.stepNum}: Emit ${event} event
|
|
244
|
+
await eventBus.publish('${event}', ${payload} as any);`;
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
// --- Call service ---
|
|
248
|
+
{
|
|
249
|
+
name: "call-service",
|
|
250
|
+
pattern: /^call\s+(\w+)\.(\w+)/i,
|
|
251
|
+
generateCall: (m, ctx) => {
|
|
252
|
+
const service = m[1];
|
|
253
|
+
const method = m[2];
|
|
254
|
+
const args = (ctx.parameterNames || []).join(", ");
|
|
255
|
+
return ` // Step ${ctx.stepNum}: Call ${service}.${method}
|
|
256
|
+
await (${toVar(service)} as any).${method}({ ${args} });`;
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
// --- Return ---
|
|
260
|
+
// Only matches when the returned value is a single declared identifier
|
|
261
|
+
// OR when the natural-language reference resolves to "the model" (e.g.
|
|
262
|
+
// "Return the user", "Return updated game"). Multi-word phrases like
|
|
263
|
+
// "Return state to caller" don't translate to valid JS, so they fall
|
|
264
|
+
// through to the unmatched-step path and emit a `// TODO:` line.
|
|
265
|
+
{
|
|
266
|
+
name: "return-model",
|
|
267
|
+
pattern: /^return\s+(?:the\s+|updated\s+|created\s+)?(\w+)\s*$/i,
|
|
268
|
+
generateCall: (m, ctx) => {
|
|
269
|
+
const valueRaw = m[1].trim();
|
|
270
|
+
const declared = ctx.declaredVars || /* @__PURE__ */ new Set();
|
|
271
|
+
const params = ctx.parameterNames || [];
|
|
272
|
+
const modelVar = toVar(ctx.modelName);
|
|
273
|
+
if (valueRaw.toLowerCase() === ctx.modelName.toLowerCase() || valueRaw === modelVar) {
|
|
274
|
+
return ` // Step ${ctx.stepNum}: Return ${ctx.modelName}
|
|
275
|
+
return ${modelVar};`;
|
|
276
|
+
}
|
|
277
|
+
if (declared.has(valueRaw)) {
|
|
278
|
+
return ` // Step ${ctx.stepNum}: Return ${valueRaw}
|
|
279
|
+
return ${valueRaw};`;
|
|
280
|
+
}
|
|
281
|
+
if (params.includes(valueRaw)) {
|
|
282
|
+
return ` // Step ${ctx.stepNum}: Return ${valueRaw}
|
|
283
|
+
return args.${valueRaw};`;
|
|
284
|
+
}
|
|
285
|
+
return ` // Step ${ctx.stepNum}: Return ${valueRaw} \u2014 TODO: resolve binding
|
|
286
|
+
return null;`;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
];
|
|
290
|
+
function matchMongoStep(step, ctx) {
|
|
291
|
+
for (const convention of MONGO_STEP_CONVENTIONS) {
|
|
292
|
+
const m = step.match(convention.pattern);
|
|
293
|
+
if (m) {
|
|
294
|
+
const call = convention.generateCall(m, ctx);
|
|
295
|
+
if (call) {
|
|
296
|
+
return { matched: true, call };
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const functionName = toMethod(step);
|
|
301
|
+
const declared = Array.from(ctx.declaredVars || []);
|
|
302
|
+
const paramNames = ctx.parameterNames || [];
|
|
303
|
+
const inputs = [...paramNames, ...declared];
|
|
304
|
+
const resultVar = ctx.resultName || `step${ctx.stepNum}Result`;
|
|
305
|
+
if (ctx.declaredVars) ctx.declaredVars.add(resultVar);
|
|
306
|
+
const inputObj = inputs.length > 0 ? `{ ${inputs.map((n) => paramNames.includes(n) ? `${n}: args.${n}` : n).join(", ")} }` : "{}";
|
|
307
|
+
return {
|
|
308
|
+
matched: false,
|
|
309
|
+
call: ` // Step ${ctx.stepNum}: ${step} [AI-generated \u2014 pure function]
|
|
310
|
+
const ${resultVar} = await aiBehaviors.${functionName}(${inputObj});`,
|
|
311
|
+
functionName,
|
|
312
|
+
inputs,
|
|
313
|
+
resultVar
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
export {
|
|
317
|
+
MONGO_STEP_CONVENTIONS,
|
|
318
|
+
matchMongoStep
|
|
319
|
+
};
|