@specverse/engines 6.7.8 → 6.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai/behavior-ai-service.js +2 -2
- package/dist/ai/behavior-ai-service.js.map +1 -1
- 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 +22 -5
- package/dist/libs/instance-factories/controllers/templates/fastify/routes-generator.js +50 -15
- package/dist/libs/instance-factories/controllers/templates/fastify/server-generator.js +26 -6
- package/dist/libs/instance-factories/services/postgres-native-services.yaml +90 -0
- package/dist/libs/instance-factories/services/templates/_shared/step-matching.js +44 -0
- package/dist/libs/instance-factories/services/templates/mongodb-native/controller-generator.js +68 -13
- package/dist/libs/instance-factories/services/templates/mongodb-native/step-conventions.js +515 -0
- package/dist/libs/instance-factories/services/templates/postgres-native/client-generator.js +165 -0
- package/dist/libs/instance-factories/services/templates/postgres-native/controller-generator.js +300 -0
- package/dist/libs/instance-factories/services/templates/postgres-native/ddl-generator.js +169 -0
- package/dist/libs/instance-factories/services/templates/postgres-native/service-generator.js +65 -0
- package/dist/libs/instance-factories/services/templates/postgres-native/step-conventions.js +433 -0
- package/dist/libs/instance-factories/services/templates/prisma/ai-behaviors-generator.js +27 -4
- package/dist/libs/instance-factories/services/templates/prisma/step-conventions.js +7 -34
- 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 +30 -3
- package/dist/realize/index.js.map +1 -1
- package/libs/instance-factories/applications/templates/generic/backend-package-json-generator.ts +46 -24
- package/libs/instance-factories/controllers/templates/fastify/routes-generator.ts +80 -21
- package/libs/instance-factories/controllers/templates/fastify/server-generator.ts +48 -7
- package/libs/instance-factories/services/postgres-native-services.yaml +90 -0
- package/libs/instance-factories/services/templates/_shared/step-matching.ts +103 -0
- package/libs/instance-factories/services/templates/mongodb-native/controller-generator.ts +97 -23
- package/libs/instance-factories/services/templates/mongodb-native/step-conventions.ts +691 -0
- package/libs/instance-factories/services/templates/postgres-native/__tests__/controller-generator.test.ts +193 -0
- package/libs/instance-factories/services/templates/postgres-native/client-generator.ts +178 -0
- package/libs/instance-factories/services/templates/postgres-native/controller-generator.ts +372 -0
- package/libs/instance-factories/services/templates/postgres-native/ddl-generator.ts +236 -0
- package/libs/instance-factories/services/templates/postgres-native/service-generator.ts +84 -0
- package/libs/instance-factories/services/templates/postgres-native/step-conventions.ts +539 -0
- package/libs/instance-factories/services/templates/prisma/ai-behaviors-generator.ts +61 -7
- package/libs/instance-factories/services/templates/prisma/step-conventions.ts +21 -68
- package/package.json +4 -3
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step Conventions for PostgreSQL native (pg) — mirrors the mongo-native
|
|
3
|
+
* library's pattern set but emits raw-SQL via the pgClient helpers
|
|
4
|
+
* (`insertOne` / `findOneByField` / `updateOneById` / etc.) instead of
|
|
5
|
+
* collection methods.
|
|
6
|
+
*
|
|
7
|
+
* Same convention names + same pattern texts, so spec authors can swap
|
|
8
|
+
* MongoDBNativeDriver for PostgresNativeDriver in the manifest without
|
|
9
|
+
* touching their .specly. The shared matcher (`matchAgainstConventions`
|
|
10
|
+
* in `../_shared/step-matching.ts`) drives both libraries — this file
|
|
11
|
+
* is purely the conventions array + a pg-flavoured `matchPgStep`
|
|
12
|
+
* delegate. (#43K-D follow-up: third ORM library demonstrating the
|
|
13
|
+
* abstraction.)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
toMethod,
|
|
18
|
+
toVar,
|
|
19
|
+
matchAgainstConventions,
|
|
20
|
+
type SharedConvention,
|
|
21
|
+
type SharedStepContext,
|
|
22
|
+
} from '../_shared/step-matching.js';
|
|
23
|
+
|
|
24
|
+
export type PgStepConvention = SharedConvention<PgStepContext>;
|
|
25
|
+
|
|
26
|
+
export interface PgStepContext extends SharedStepContext {
|
|
27
|
+
/** Table name for the model (lowercased + 's' or storage.table). */
|
|
28
|
+
tableName: string;
|
|
29
|
+
/** Full model registry so conventions can read attributes for default
|
|
30
|
+
* values, types, and FK target tables. */
|
|
31
|
+
models?: Record<string, any>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Compute sensible default values for a model's attributes — the
|
|
36
|
+
* postgres-native counterpart of mongodb-native's `deriveModelDefaults`.
|
|
37
|
+
* Same heuristics; same conventionManaged exclusions.
|
|
38
|
+
*
|
|
39
|
+
* Defaults emitted as `key: expression` strings ready to inline into an
|
|
40
|
+
* object-literal record body (the body is then handed to `insertOne`,
|
|
41
|
+
* which does the SQL plumbing). Empty if the model isn't in the registry.
|
|
42
|
+
*/
|
|
43
|
+
export function deriveModelDefaults(
|
|
44
|
+
modelName: string,
|
|
45
|
+
ctx: PgStepContext,
|
|
46
|
+
): string[] {
|
|
47
|
+
if (!ctx.models) return [];
|
|
48
|
+
const model = ctx.models[modelName] || ctx.models[modelName.charAt(0).toUpperCase() + modelName.slice(1)];
|
|
49
|
+
if (!model) return [];
|
|
50
|
+
const attrs = model.attributes;
|
|
51
|
+
if (!attrs) return [];
|
|
52
|
+
const list = Array.isArray(attrs)
|
|
53
|
+
? attrs.map((a: any) => [a.name, a])
|
|
54
|
+
: Object.entries(attrs);
|
|
55
|
+
|
|
56
|
+
const declaredVars = ctx.declaredVars || new Set();
|
|
57
|
+
const out: string[] = [];
|
|
58
|
+
|
|
59
|
+
// The "id" column is database-managed (BIGSERIAL or DEFAULT gen_random_uuid())
|
|
60
|
+
// so we don't emit it from the convention. createdAt/updatedAt come from
|
|
61
|
+
// the convention footer.
|
|
62
|
+
const conventionManaged = new Set(['createdAt', 'updatedAt', 'id']);
|
|
63
|
+
for (const [name, attr] of list as [string, any][]) {
|
|
64
|
+
if (!name) continue;
|
|
65
|
+
if (conventionManaged.has(name)) continue;
|
|
66
|
+
const required = !!attr.required;
|
|
67
|
+
const hasDefault = attr.default !== undefined;
|
|
68
|
+
if (!required && !hasDefault) continue;
|
|
69
|
+
if (hasDefault) {
|
|
70
|
+
out.push(`${name}: ${formatDefault(attr.default, attr.type)}`);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const type = (attr.type || 'String').toLowerCase();
|
|
74
|
+
if (type === 'integer' || type === 'int' || type === 'number' || type === 'float') {
|
|
75
|
+
const min = attr.min ?? 0;
|
|
76
|
+
out.push(`${name}: ${min}`);
|
|
77
|
+
} else if (type === 'boolean') {
|
|
78
|
+
out.push(`${name}: false`);
|
|
79
|
+
} else if (type === 'datetime' || type === 'date') {
|
|
80
|
+
out.push(`${name}: new Date().toISOString()`);
|
|
81
|
+
} else {
|
|
82
|
+
// String / UUID / Text — try FK resolution first.
|
|
83
|
+
const selfVar = modelName.charAt(0).toLowerCase() + modelName.slice(1);
|
|
84
|
+
const fkMatch = name.match(/^(.+)Id$/);
|
|
85
|
+
if (fkMatch && fkMatch[1] !== selfVar && declaredVars.has(fkMatch[1])) {
|
|
86
|
+
out.push(`${name}: (${fkMatch[1]} as any)?.id`);
|
|
87
|
+
} else {
|
|
88
|
+
out.push(`${name}: ''`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function formatDefault(value: any, type?: string): string {
|
|
96
|
+
if (value === null || value === undefined) return 'null';
|
|
97
|
+
if (typeof value === 'boolean') return String(value);
|
|
98
|
+
if (typeof value === 'number') return String(value);
|
|
99
|
+
if (typeof value === 'string') {
|
|
100
|
+
if (/^-?\d+(\.\d+)?$/.test(value)) return value;
|
|
101
|
+
if (value === 'true' || value === 'false') return value;
|
|
102
|
+
if (value === 'now' && (type === 'DateTime' || type === 'Date')) {
|
|
103
|
+
return 'new Date().toISOString()';
|
|
104
|
+
}
|
|
105
|
+
return JSON.stringify(value);
|
|
106
|
+
}
|
|
107
|
+
return JSON.stringify(value);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function toTable(modelName: string): string {
|
|
111
|
+
return modelName.toLowerCase() + 's';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Resolve a value reference in step text to a TS expression. Mirrors
|
|
115
|
+
* mongo's resolveValue so spec phrases map identically. */
|
|
116
|
+
function resolveValue(rawValue: string, ctx: PgStepContext): string {
|
|
117
|
+
const value = rawValue.trim().replace(/^['"]|['"]$/g, '');
|
|
118
|
+
if (/^(current\s*time|now|timestamp)$/i.test(value)) return 'new Date().toISOString()';
|
|
119
|
+
if (/^-?\d+(\.\d+)?$/.test(value)) return value;
|
|
120
|
+
if (value === 'true' || value === 'false') return value;
|
|
121
|
+
if (value === 'null') return 'null';
|
|
122
|
+
const declared = ctx.declaredVars || new Set();
|
|
123
|
+
const params = ctx.parameterNames || [];
|
|
124
|
+
const head = value.split('.')[0]!;
|
|
125
|
+
if (declared.has(head)) return value;
|
|
126
|
+
if (params.includes(head)) return `args.${value}`;
|
|
127
|
+
return `'${value.replace(/'/g, "\\'")}'`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function mostRecentStepResult(ctx: PgStepContext): string | null {
|
|
131
|
+
const declared = Array.from(ctx.declaredVars || []);
|
|
132
|
+
const stepResults = declared.filter((v) => /^step\d+Result$/.test(v))
|
|
133
|
+
.sort((a, b) => parseInt(b.slice(4), 10) - parseInt(a.slice(4), 10));
|
|
134
|
+
return stepResults[0] ?? null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function pascal(model: string): string {
|
|
138
|
+
return model.charAt(0).toUpperCase() + model.slice(1);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const PG_STEP_CONVENTIONS: PgStepConvention[] = [
|
|
142
|
+
// --- Find / Lookup by single field ---
|
|
143
|
+
{
|
|
144
|
+
name: 'find-by-field',
|
|
145
|
+
pattern: /^(?:look\s+up|find|fetch|get)\s+(\w+)\s+by\s+(\w+)(?:\s+or\s+fail.*)?$/i,
|
|
146
|
+
generateCall: (m, ctx) => {
|
|
147
|
+
const model = m[1]!;
|
|
148
|
+
const field = m[2]!;
|
|
149
|
+
const modelVar = toVar(model);
|
|
150
|
+
const table = toTable(model);
|
|
151
|
+
const params = ctx.parameterNames || [];
|
|
152
|
+
const declared = ctx.declaredVars || new Set();
|
|
153
|
+
const idVal = field === 'id'
|
|
154
|
+
? (params.includes(`${modelVar}Id`) ? `args.${modelVar}Id` : 'args.id')
|
|
155
|
+
: `args.${field}`;
|
|
156
|
+
|
|
157
|
+
if (declared.has(modelVar)) {
|
|
158
|
+
return ` // Step ${ctx.stepNum}: Find ${model} by ${field} (already loaded)`;
|
|
159
|
+
}
|
|
160
|
+
declared.add(modelVar);
|
|
161
|
+
const failOnMissing = /or\s+fail/i.test(m[0]);
|
|
162
|
+
// `let` so subsequent conditional-create can reassign on null lookup.
|
|
163
|
+
return ` // Step ${ctx.stepNum}: Find ${model} by ${field}
|
|
164
|
+
let ${modelVar} = await findOneByField('${table}', '${field}', ${idVal}) as any;${failOnMissing ? `
|
|
165
|
+
if (!${modelVar}) throw new Error('${model} not found');` : ''}`;
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
// --- Find by composite (two fields) ---
|
|
170
|
+
{
|
|
171
|
+
name: 'find-by-composite',
|
|
172
|
+
pattern: /^(?:look\s+up|find|fetch|get)(?:\s+existing)?\s+(\w+)\s+by\s+(\w+)\s+and\s+(\w+)$/i,
|
|
173
|
+
generateCall: (m, ctx) => {
|
|
174
|
+
const model = m[1]!;
|
|
175
|
+
const f1 = m[2]!;
|
|
176
|
+
const f2 = m[3]!;
|
|
177
|
+
const modelVar = toVar(model);
|
|
178
|
+
const table = toTable(model);
|
|
179
|
+
const declared = ctx.declaredVars || new Set();
|
|
180
|
+
if (declared.has(modelVar)) {
|
|
181
|
+
return ` // Step ${ctx.stepNum}: Find ${model} by ${f1} and ${f2} (already loaded)`;
|
|
182
|
+
}
|
|
183
|
+
declared.add(modelVar);
|
|
184
|
+
const resolveFieldSource = (f: string) => {
|
|
185
|
+
const stripIdMatch = f.match(/^(.+)Id$/);
|
|
186
|
+
if (stripIdMatch) {
|
|
187
|
+
const candidate = stripIdMatch[1]!;
|
|
188
|
+
if (candidate !== modelVar && declared.has(candidate)) {
|
|
189
|
+
return `(${candidate} as any)?.id`;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return `args.${f}`;
|
|
193
|
+
};
|
|
194
|
+
const f1Src = resolveFieldSource(f1);
|
|
195
|
+
const f2Src = resolveFieldSource(f2);
|
|
196
|
+
return ` // Step ${ctx.stepNum}: Find ${model} by ${f1} and ${f2}
|
|
197
|
+
let ${modelVar} = await findOneByFields('${table}', { ${f1}: ${f1Src}, ${f2}: ${f2Src} }) as any;`;
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
// --- Create model record ---
|
|
202
|
+
{
|
|
203
|
+
name: 'create',
|
|
204
|
+
pattern: /^create\s+(?:new\s+)?(\w+)(?:\s+(?:with\s+)?(.+))?/i,
|
|
205
|
+
generateCall: (m, ctx) => {
|
|
206
|
+
const model = m[1]!;
|
|
207
|
+
const modelVar = toVar(model);
|
|
208
|
+
const table = toTable(model);
|
|
209
|
+
const params = ctx.parameterNames || [];
|
|
210
|
+
const declared = ctx.declaredVars || new Set();
|
|
211
|
+
declared.add(modelVar);
|
|
212
|
+
const dataExpr = params.length > 0 ? `{ ${params.join(', ')} }` : 'args';
|
|
213
|
+
return ` // Step ${ctx.stepNum}: Create ${model}
|
|
214
|
+
const ${modelVar} = await insertOne('${table}', ${dataExpr}) as any;`;
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
// --- Update specific field on previously-loaded model ---
|
|
219
|
+
{
|
|
220
|
+
name: 'update-field',
|
|
221
|
+
pattern: /^update\s+(\w+)\s+(\w+)\s+to\s+(.+)/i,
|
|
222
|
+
generateCall: (m, ctx) => {
|
|
223
|
+
const model = m[1]!;
|
|
224
|
+
const field = m[2]!;
|
|
225
|
+
const rawValue = m[3]!;
|
|
226
|
+
const modelVar = toVar(model);
|
|
227
|
+
if (!ctx.declaredVars?.has(modelVar)) return '';
|
|
228
|
+
const table = toTable(model);
|
|
229
|
+
const val = resolveValue(rawValue, ctx);
|
|
230
|
+
return ` // Step ${ctx.stepNum}: Update ${model}.${field} to ${rawValue.trim()}
|
|
231
|
+
await updateOneById('${table}', ${modelVar}.id, { ${field}: ${val} });`;
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
// --- Update field timestamp ---
|
|
236
|
+
{
|
|
237
|
+
name: 'update-field-timestamp',
|
|
238
|
+
pattern: /^update\s+(\w+)\s+(\w+)\s+timestamp$/i,
|
|
239
|
+
generateCall: (m, ctx) => {
|
|
240
|
+
const model = m[1]!;
|
|
241
|
+
const field = m[2]!;
|
|
242
|
+
const modelVar = toVar(model);
|
|
243
|
+
if (!ctx.declaredVars?.has(modelVar)) return '';
|
|
244
|
+
const table = toTable(model);
|
|
245
|
+
return ` // Step ${ctx.stepNum}: Update ${model}.${field} timestamp
|
|
246
|
+
await updateOneById('${table}', ${modelVar}.id, { ${field}: new Date().toISOString() });`;
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
// --- Generic "Update X" (writes args back) ---
|
|
251
|
+
{
|
|
252
|
+
name: 'update',
|
|
253
|
+
pattern: /^update\s+(\w+)(?:\s+(.+))?$/i,
|
|
254
|
+
generateCall: (m, ctx) => {
|
|
255
|
+
const model = m[1]!;
|
|
256
|
+
const modelVar = toVar(model);
|
|
257
|
+
if (!ctx.declaredVars?.has(modelVar)) return '';
|
|
258
|
+
const table = toTable(model);
|
|
259
|
+
return ` // Step ${ctx.stepNum}: Update ${model}
|
|
260
|
+
await updateOneById('${table}', ${modelVar}.id, args);`;
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
// --- Delete model record ---
|
|
265
|
+
{
|
|
266
|
+
name: 'delete',
|
|
267
|
+
pattern: /^delete\s+(\w+)/i,
|
|
268
|
+
generateCall: (m, ctx) => {
|
|
269
|
+
const model = m[1]!;
|
|
270
|
+
const modelVar = toVar(model);
|
|
271
|
+
if (!ctx.declaredVars?.has(modelVar)) return '';
|
|
272
|
+
const table = toTable(model);
|
|
273
|
+
return ` // Step ${ctx.stepNum}: Delete ${model}
|
|
274
|
+
await deleteOneById('${table}', ${modelVar}.id);`;
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
// --- Transition to lifecycle state ---
|
|
279
|
+
{
|
|
280
|
+
name: 'transition',
|
|
281
|
+
pattern: /^transition\s+(\w+)\s+to\s+(\w+)/i,
|
|
282
|
+
generateCall: (m, ctx) => {
|
|
283
|
+
const model = m[1]!;
|
|
284
|
+
const state = m[2]!;
|
|
285
|
+
const modelVar = toVar(model);
|
|
286
|
+
if (!ctx.declaredVars?.has(modelVar)) return '';
|
|
287
|
+
const table = toTable(model);
|
|
288
|
+
return ` // Step ${ctx.stepNum}: Transition ${model} to ${state}
|
|
289
|
+
if ((${modelVar} as any).status === '${state}') throw new Error('${model} is already ${state}');
|
|
290
|
+
await updateOneById('${table}', ${modelVar}.id, { status: '${state}' });`;
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
// --- Set field to value (on the controller's primary model) ---
|
|
295
|
+
{
|
|
296
|
+
name: 'set',
|
|
297
|
+
pattern: /^set\s+(\w+)\s+to\s+(.+)/i,
|
|
298
|
+
generateCall: (m, ctx) => {
|
|
299
|
+
const field = m[1]!;
|
|
300
|
+
const rawValue = m[2]!;
|
|
301
|
+
const modelVar = toVar(ctx.modelName);
|
|
302
|
+
const val = resolveValue(rawValue, ctx);
|
|
303
|
+
return ` // Step ${ctx.stepNum}: Set ${field} to ${rawValue.trim()}
|
|
304
|
+
await updateOneById(TABLE_NAME, (${modelVar} as any).id, { ${field}: ${val} });`;
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
|
|
308
|
+
// --- Increment / Decrement ---
|
|
309
|
+
// Postgres has no Mongo $inc; emit a column = column +/- N update. The
|
|
310
|
+
// helper updateOneById doesn't support raw SQL fragments, so emit query
|
|
311
|
+
// directly here.
|
|
312
|
+
{
|
|
313
|
+
name: 'increment',
|
|
314
|
+
pattern: /^increment\s+(\w+)\s+by\s+(\w+)/i,
|
|
315
|
+
generateCall: (m, ctx) => {
|
|
316
|
+
const field = m[1]!;
|
|
317
|
+
const amount = m[2]!;
|
|
318
|
+
const modelVar = toVar(ctx.modelName);
|
|
319
|
+
return ` // Step ${ctx.stepNum}: Increment ${field} by ${amount}
|
|
320
|
+
await query(\`UPDATE "\${TABLE_NAME}" SET "${field}" = "${field}" + $1 WHERE "id" = $2\`, [${amount}, (${modelVar} as any).id]);`;
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
name: 'decrement',
|
|
325
|
+
pattern: /^decrement\s+(\w+)\s+by\s+(\w+)/i,
|
|
326
|
+
generateCall: (m, ctx) => {
|
|
327
|
+
const field = m[1]!;
|
|
328
|
+
const amount = m[2]!;
|
|
329
|
+
const modelVar = toVar(ctx.modelName);
|
|
330
|
+
return ` // Step ${ctx.stepNum}: Decrement ${field} by ${amount}
|
|
331
|
+
await query(\`UPDATE "\${TABLE_NAME}" SET "${field}" = "${field}" - $1 WHERE "id" = $2\`, [${amount}, (${modelVar} as any).id]);`;
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
|
|
335
|
+
// --- Persist / Save / Store ---
|
|
336
|
+
{
|
|
337
|
+
name: 'persist',
|
|
338
|
+
pattern: /^(?:persist|save|store)\s+(\w+(?:\s+\w+)?)(?:\s+(?:for|to|record).*)?$/i,
|
|
339
|
+
generateCall: (m, ctx) => {
|
|
340
|
+
const target = toVar(m[1]!.replace(/\s+(.)/g, (_, c) => c.toUpperCase()));
|
|
341
|
+
const table = toTable(target);
|
|
342
|
+
const recordSrc = mostRecentStepResult(ctx) ?? 'args';
|
|
343
|
+
// Ensure `record` is an object before insertOne — wrap primitives.
|
|
344
|
+
return ` // Step ${ctx.stepNum}: Persist ${m[1]}
|
|
345
|
+
{
|
|
346
|
+
const _rec: any = ${recordSrc} && typeof ${recordSrc} === 'object' && !Array.isArray(${recordSrc})
|
|
347
|
+
? ${recordSrc} : { value: ${recordSrc} };
|
|
348
|
+
await insertOne('${table}', _rec);
|
|
349
|
+
}`;
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
|
|
353
|
+
// --- Conditional create ---
|
|
354
|
+
{
|
|
355
|
+
name: 'conditional-create',
|
|
356
|
+
pattern: /^if\s+(\w+)\s+does\s+not\s+exist,?\s+create\s+new\s+(\w+)(?:\s+with\s+.+)?$/i,
|
|
357
|
+
generateCall: (m, ctx) => {
|
|
358
|
+
const modelVar = toVar(m[1]!);
|
|
359
|
+
const Model = pascal(m[2]!);
|
|
360
|
+
const table = toTable(Model);
|
|
361
|
+
if (!ctx.declaredVars?.has(modelVar)) return '';
|
|
362
|
+
const defaults = deriveModelDefaults(Model, ctx);
|
|
363
|
+
const defaultsBlock = defaults.length > 0 ? defaults.join(', ') + ',' : '';
|
|
364
|
+
return ` // Step ${ctx.stepNum}: If ${modelVar} does not exist, create new ${Model}
|
|
365
|
+
if (!${modelVar}) {
|
|
366
|
+
const _newRecord = { ${defaultsBlock} ...args, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() };
|
|
367
|
+
${modelVar} = await insertOne('${table}', _newRecord) as any;
|
|
368
|
+
}`;
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
|
|
372
|
+
// --- Conditional update ---
|
|
373
|
+
{
|
|
374
|
+
name: 'conditional-update',
|
|
375
|
+
pattern: /^if\s+(\w+)\s+exists,?\s+update\s+(\w+)(?:\s+(.+))?$/i,
|
|
376
|
+
generateCall: (m, ctx) => {
|
|
377
|
+
const modelVar = toVar(m[1]!);
|
|
378
|
+
const field = m[2]!;
|
|
379
|
+
const table = toTable(m[1]!);
|
|
380
|
+
if (!ctx.declaredVars?.has(modelVar)) return '';
|
|
381
|
+
return ` // Step ${ctx.stepNum}: If ${modelVar} exists, update ${field}
|
|
382
|
+
if (${modelVar}) {
|
|
383
|
+
await updateOneById('${table}', (${modelVar} as any).id, { ${field}: new Date().toISOString() });
|
|
384
|
+
}`;
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
|
|
388
|
+
// --- Auto-create / Bulk fan-out ---
|
|
389
|
+
{
|
|
390
|
+
name: 'auto-create-loop',
|
|
391
|
+
pattern: /^(?:auto-create|bulk\s+create)\s+(\w+)\s+(?:profile|record|entry|entries)?s?\s+for\s+(?:all\s+)?(?:available\s+)?(\w+)$/i,
|
|
392
|
+
generateCall: (m, ctx) => {
|
|
393
|
+
const Model = pascal(m[1]!);
|
|
394
|
+
const targetTable = toTable(Model);
|
|
395
|
+
const sourceTable = m[2]!.toLowerCase();
|
|
396
|
+
const declared = Array.from(ctx.declaredVars || []);
|
|
397
|
+
const ownerVar = declared.find((v) => !/^step\d+Result$/.test(v) && v !== 'args');
|
|
398
|
+
const sourceSingular = sourceTable.replace(/s$/, '');
|
|
399
|
+
const linkField = sourceSingular + 'Id';
|
|
400
|
+
const ownerLinkField = ownerVar ? ownerVar + 'Id' : 'ownerId';
|
|
401
|
+
|
|
402
|
+
const modelDefaults = deriveModelDefaults(Model, ctx);
|
|
403
|
+
const defaultsBlock = modelDefaults.length > 0
|
|
404
|
+
? modelDefaults.filter((d) => !d.startsWith(`${ownerLinkField}:`) && !d.startsWith(`${linkField}:`)).join(', ')
|
|
405
|
+
: '';
|
|
406
|
+
|
|
407
|
+
return ` // Step ${ctx.stepNum}: Auto-create ${m[1]} ${m[2]} for all ${m[2]}
|
|
408
|
+
{
|
|
409
|
+
const _allItems = await findAll('${sourceTable}');
|
|
410
|
+
const _ownerId = ${ownerVar ? `(${ownerVar} as any)?.id` : 'null'};
|
|
411
|
+
const _records = _allItems.map((_item: any) => ({
|
|
412
|
+
${defaultsBlock ? defaultsBlock + ',' : ''}
|
|
413
|
+
${ownerLinkField}: _ownerId,
|
|
414
|
+
${linkField}: (_item as any).${sourceSingular}Id ?? (_item as any).id,
|
|
415
|
+
createdAt: new Date().toISOString(),
|
|
416
|
+
updatedAt: new Date().toISOString(),
|
|
417
|
+
}));
|
|
418
|
+
if (_records.length > 0) {
|
|
419
|
+
await insertMany('${targetTable}', _records);
|
|
420
|
+
}
|
|
421
|
+
}`;
|
|
422
|
+
},
|
|
423
|
+
},
|
|
424
|
+
|
|
425
|
+
// --- "Otherwise create new X record" ---
|
|
426
|
+
{
|
|
427
|
+
name: 'otherwise-create',
|
|
428
|
+
pattern: /^otherwise\s+create\s+(?:new\s+)?(\w+)\s+record$/i,
|
|
429
|
+
generateCall: (m, ctx) => {
|
|
430
|
+
const Model = pascal(m[1]!);
|
|
431
|
+
const modelVar = toVar(Model);
|
|
432
|
+
const table = toTable(Model);
|
|
433
|
+
const wasDeclared = ctx.declaredVars?.has(modelVar);
|
|
434
|
+
const declared = Array.from(ctx.declaredVars || []);
|
|
435
|
+
const defaults = deriveModelDefaults(Model, ctx);
|
|
436
|
+
const supplied = new Set<string>();
|
|
437
|
+
for (const entry of defaults) {
|
|
438
|
+
const colonIdx = entry.indexOf(':');
|
|
439
|
+
if (colonIdx > 0) supplied.add(entry.slice(0, colonIdx).trim());
|
|
440
|
+
}
|
|
441
|
+
const fkAssignments = declared
|
|
442
|
+
.filter((v) => v !== modelVar && !/^step\d+Result$/.test(v) && v !== 'args')
|
|
443
|
+
.filter((v) => !supplied.has(v + 'Id'))
|
|
444
|
+
.map((v) => {
|
|
445
|
+
supplied.add(v + 'Id');
|
|
446
|
+
return `${v}Id: (${v} as any)?.id`;
|
|
447
|
+
})
|
|
448
|
+
.join(', ');
|
|
449
|
+
const defaultsBlock = defaults.length > 0 ? defaults.join(', ') + ',' : '';
|
|
450
|
+
ctx.declaredVars?.add(modelVar);
|
|
451
|
+
return ` // Step ${ctx.stepNum}: Otherwise create new ${Model} record
|
|
452
|
+
else {
|
|
453
|
+
const _newRecord = { ${defaultsBlock} ${fkAssignments ? fkAssignments + ',' : ''} ...args, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() };
|
|
454
|
+
${wasDeclared ? `${modelVar} = await insertOne('${table}', _newRecord) as any;` : `const ${modelVar} = await insertOne('${table}', _newRecord) as any;
|
|
455
|
+
void ${modelVar};`}
|
|
456
|
+
}`;
|
|
457
|
+
},
|
|
458
|
+
},
|
|
459
|
+
|
|
460
|
+
// --- Send/Emit/Publish event ---
|
|
461
|
+
{
|
|
462
|
+
name: 'send-event',
|
|
463
|
+
pattern: /^(?:send|emit|publish)\s+(\w+)\s+event/i,
|
|
464
|
+
generateCall: (m, ctx) => {
|
|
465
|
+
const event = m[1]!;
|
|
466
|
+
const modelVar = toVar(ctx.modelName);
|
|
467
|
+
const hasModelVar = ctx.declaredVars?.has(modelVar);
|
|
468
|
+
const payload = hasModelVar
|
|
469
|
+
? `{ ${modelVar}Id: (${modelVar} as any)?.id, operation: '${ctx.operationName}', timestamp: new Date().toISOString() }`
|
|
470
|
+
: `{ operation: '${ctx.operationName}', timestamp: new Date().toISOString() }`;
|
|
471
|
+
return ` // Step ${ctx.stepNum}: Emit ${event} event
|
|
472
|
+
await eventBus.publish('${event}', ${payload} as any);`;
|
|
473
|
+
},
|
|
474
|
+
},
|
|
475
|
+
|
|
476
|
+
// --- Call service ---
|
|
477
|
+
{
|
|
478
|
+
name: 'call-service',
|
|
479
|
+
pattern: /^call\s+(\w+)\.(\w+)/i,
|
|
480
|
+
generateCall: (m, ctx) => {
|
|
481
|
+
const service = m[1]!;
|
|
482
|
+
const method = m[2]!;
|
|
483
|
+
const args = (ctx.parameterNames || []).join(', ');
|
|
484
|
+
return ` // Step ${ctx.stepNum}: Call ${service}.${method}
|
|
485
|
+
await (${toVar(service)} as any).${method}({ ${args} });`;
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
|
|
489
|
+
// --- Return ---
|
|
490
|
+
{
|
|
491
|
+
name: 'return-model',
|
|
492
|
+
pattern: /^return\s+(?:the\s+|updated\s+|created\s+)?(\w+)\s*$/i,
|
|
493
|
+
generateCall: (m, ctx) => {
|
|
494
|
+
const valueRaw = m[1]!.trim();
|
|
495
|
+
const declared = ctx.declaredVars || new Set();
|
|
496
|
+
const params = ctx.parameterNames || [];
|
|
497
|
+
const modelVar = toVar(ctx.modelName);
|
|
498
|
+
if (valueRaw.toLowerCase() === ctx.modelName.toLowerCase() || valueRaw === modelVar) {
|
|
499
|
+
return ` // Step ${ctx.stepNum}: Return ${ctx.modelName}
|
|
500
|
+
return ${modelVar};`;
|
|
501
|
+
}
|
|
502
|
+
if (declared.has(valueRaw)) {
|
|
503
|
+
return ` // Step ${ctx.stepNum}: Return ${valueRaw}
|
|
504
|
+
return ${valueRaw};`;
|
|
505
|
+
}
|
|
506
|
+
if (params.includes(valueRaw)) {
|
|
507
|
+
return ` // Step ${ctx.stepNum}: Return ${valueRaw}
|
|
508
|
+
return args.${valueRaw};`;
|
|
509
|
+
}
|
|
510
|
+
return ` // Step ${ctx.stepNum}: Return ${valueRaw} — TODO: resolve binding
|
|
511
|
+
return null;`;
|
|
512
|
+
},
|
|
513
|
+
},
|
|
514
|
+
];
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Match a step against the postgres-native conventions. Same shape as
|
|
518
|
+
* `matchStep` (prisma) and `matchMongoStep` — the orm-agnostic
|
|
519
|
+
* AI-behaviors-generator can drive any of the three.
|
|
520
|
+
*/
|
|
521
|
+
export function matchPgStep(
|
|
522
|
+
step: string,
|
|
523
|
+
ctx: PgStepContext,
|
|
524
|
+
): {
|
|
525
|
+
matched: boolean;
|
|
526
|
+
call: string;
|
|
527
|
+
helperMethod?: string;
|
|
528
|
+
functionName?: string;
|
|
529
|
+
inputs?: string[];
|
|
530
|
+
resultVar?: string;
|
|
531
|
+
} {
|
|
532
|
+
// Same `args.X` plumbing as mongo-native — the controller wraps the
|
|
533
|
+
// action body with a single `args` parameter rather than destructuring.
|
|
534
|
+
const aiArgsExpr = (inputs: string[], paramNames: string[]) =>
|
|
535
|
+
inputs.length > 0
|
|
536
|
+
? `{ ${inputs.map((n) => paramNames.includes(n) ? `${n}: args.${n}` : n).join(', ')} }`
|
|
537
|
+
: '{}';
|
|
538
|
+
return matchAgainstConventions(step, ctx, PG_STEP_CONVENTIONS, aiArgsExpr);
|
|
539
|
+
}
|
|
@@ -135,7 +135,7 @@ async function validateTypeScriptTypes(code: string): Promise<string | null> {
|
|
|
135
135
|
* produces a new hash. The prompt version is part of the hash so
|
|
136
136
|
* prompt upgrades also invalidate.
|
|
137
137
|
*/
|
|
138
|
-
const PROMPT_VERSION = '9.
|
|
138
|
+
const PROMPT_VERSION = '9.7.0';
|
|
139
139
|
|
|
140
140
|
function cacheKey(step: string, modelName: string, operationName: string, functionName: string, inputs: string[]): string {
|
|
141
141
|
const payload = JSON.stringify({ step, modelName, operationName, functionName, inputs: [...inputs].sort(), v: PROMPT_VERSION });
|
|
@@ -173,10 +173,33 @@ function cacheWrite(key: string, body: string): void {
|
|
|
173
173
|
*
|
|
174
174
|
* This is async — it calls the AI engine to generate function bodies.
|
|
175
175
|
*/
|
|
176
|
-
|
|
176
|
+
/** Optional matcher for orm-agnostic step matching. When present, this is
|
|
177
|
+
* used in place of the prisma library's `matchStep` so that the unmatched
|
|
178
|
+
* functions' names + inputs are computed by the same conventions library
|
|
179
|
+
* the consuming controller is using. Without this, mongo-native (or any
|
|
180
|
+
* non-prisma) controllers would diverge: they'd emit `aiBehaviors.X` calls
|
|
181
|
+
* with one inputs set while this generator would emit a function with a
|
|
182
|
+
* different signature. */
|
|
183
|
+
type StepMatcher = (step: string, ctx: any) => {
|
|
184
|
+
matched: boolean;
|
|
185
|
+
call?: string;
|
|
186
|
+
helperMethod?: string;
|
|
187
|
+
functionName?: string;
|
|
188
|
+
inputs?: string[];
|
|
189
|
+
resultVar?: string;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
export default async function generateAiBehaviors(
|
|
193
|
+
context: TemplateContext,
|
|
194
|
+
matcher?: StepMatcher,
|
|
195
|
+
): Promise<string> {
|
|
177
196
|
const { controller, model } = context;
|
|
178
197
|
if (!controller?.actions) return '';
|
|
179
198
|
|
|
199
|
+
// Default matcher = prisma's matchStep for back-compat. Mongo-native
|
|
200
|
+
// (and any future ORM) passes its own matcher.
|
|
201
|
+
const stepMatcher: StepMatcher = matcher || (matchStep as any);
|
|
202
|
+
|
|
180
203
|
const modelName = model?.name || controller.model || 'Model';
|
|
181
204
|
const modelVar = modelName.charAt(0).toLowerCase() + modelName.slice(1);
|
|
182
205
|
// List of all entity-model names available in the realized output. Forwarded
|
|
@@ -234,7 +257,7 @@ export default async function generateAiBehaviors(context: TemplateContext): Pro
|
|
|
234
257
|
resultName: stepAs,
|
|
235
258
|
};
|
|
236
259
|
|
|
237
|
-
const result =
|
|
260
|
+
const result = stepMatcher(stepText, ctx);
|
|
238
261
|
if (!result.matched && result.functionName) {
|
|
239
262
|
// Avoid duplicate function definitions
|
|
240
263
|
const existing = unmatchedFunctions.find(f => f.functionName === result.functionName);
|
|
@@ -322,13 +345,44 @@ export async function generateAiBehaviorsFile(opts: {
|
|
|
322
345
|
/** Strip string/template/comment content so identifier-reference checks
|
|
323
346
|
* don't match `reward` inside a kebab-case string like `'reward-not-granted'`
|
|
324
347
|
* or `// reward stays as text`. We replace the literal contents with
|
|
325
|
-
* spaces so positions stay roughly aligned in case of debugging.
|
|
348
|
+
* spaces so positions stay roughly aligned in case of debugging.
|
|
349
|
+
*
|
|
350
|
+
* Critical: template-string `${...}` interpolations are EXPRESSIONS, not
|
|
351
|
+
* literal text — they really do reference `input.X` etc. Strip only the
|
|
352
|
+
* non-interpolation segments of the template so the expression content
|
|
353
|
+
* survives the regex check. */
|
|
326
354
|
const stripLiteralsAndComments = (src: string): string => {
|
|
327
|
-
|
|
355
|
+
let out = src
|
|
328
356
|
.replace(/\/\*[\s\S]*?\*\//g, (m) => ' '.repeat(m.length))
|
|
329
357
|
.replace(/\/\/[^\n]*/g, (m) => ' '.repeat(m.length))
|
|
330
|
-
.replace(/(['"])(?:\\.|(?!\1).)*\1/g, (m) => m[0] + ' '.repeat(m.length - 2) + m[0])
|
|
331
|
-
|
|
358
|
+
.replace(/(['"])(?:\\.|(?!\1).)*\1/g, (m) => m[0] + ' '.repeat(m.length - 2) + m[0]);
|
|
359
|
+
// Strip text INSIDE backticks but preserve `${...}` expressions.
|
|
360
|
+
// We rebuild template contents by replacing literal-text spans
|
|
361
|
+
// (everything between `\`` and `${`, between `}` and `${`, or
|
|
362
|
+
// between `}` and the closing `\``) with spaces.
|
|
363
|
+
out = out.replace(/`((?:\\.|\$\{[^}]*\}|(?!`).)*)`/g, (full, content) => {
|
|
364
|
+
// Walk the content, copy `${...}` segments verbatim, replace other
|
|
365
|
+
// characters with spaces.
|
|
366
|
+
let result = '`';
|
|
367
|
+
let i = 0;
|
|
368
|
+
while (i < content.length) {
|
|
369
|
+
if (content[i] === '\\' && i + 1 < content.length) {
|
|
370
|
+
result += ' '; i += 2; continue;
|
|
371
|
+
}
|
|
372
|
+
if (content[i] === '$' && content[i + 1] === '{') {
|
|
373
|
+
// copy interpolation verbatim including the `${...}` markers
|
|
374
|
+
const end = content.indexOf('}', i);
|
|
375
|
+
const slice = end >= 0 ? content.slice(i, end + 1) : content.slice(i);
|
|
376
|
+
result += slice;
|
|
377
|
+
i += slice.length;
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
result += ' ';
|
|
381
|
+
i++;
|
|
382
|
+
}
|
|
383
|
+
return result + '`';
|
|
384
|
+
});
|
|
385
|
+
return out;
|
|
332
386
|
};
|
|
333
387
|
|
|
334
388
|
/** Detect whether the body manages its own input access (so adding our
|