@specverse/engines 6.11.2 → 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.
Files changed (31) hide show
  1. package/dist/ai/behavior-ai-service.js +2 -2
  2. package/dist/ai/behavior-ai-service.js.map +1 -1
  3. package/dist/libs/instance-factories/applications/templates/generic/backend-package-json-generator.js +22 -5
  4. package/dist/libs/instance-factories/services/postgres-native-services.yaml +90 -0
  5. package/dist/libs/instance-factories/services/templates/_shared/step-matching.js +44 -0
  6. package/dist/libs/instance-factories/services/templates/mongodb-native/controller-generator.js +18 -6
  7. package/dist/libs/instance-factories/services/templates/mongodb-native/step-conventions.js +230 -34
  8. package/dist/libs/instance-factories/services/templates/postgres-native/client-generator.js +165 -0
  9. package/dist/libs/instance-factories/services/templates/postgres-native/controller-generator.js +300 -0
  10. package/dist/libs/instance-factories/services/templates/postgres-native/ddl-generator.js +169 -0
  11. package/dist/libs/instance-factories/services/templates/postgres-native/service-generator.js +65 -0
  12. package/dist/libs/instance-factories/services/templates/postgres-native/step-conventions.js +433 -0
  13. package/dist/libs/instance-factories/services/templates/prisma/ai-behaviors-generator.js +1 -1
  14. package/dist/libs/instance-factories/services/templates/prisma/step-conventions.js +7 -34
  15. package/dist/realize/index.d.ts.map +1 -1
  16. package/dist/realize/index.js +8 -0
  17. package/dist/realize/index.js.map +1 -1
  18. package/libs/instance-factories/applications/templates/generic/backend-package-json-generator.ts +46 -24
  19. package/libs/instance-factories/services/postgres-native-services.yaml +90 -0
  20. package/libs/instance-factories/services/templates/_shared/step-matching.ts +103 -0
  21. package/libs/instance-factories/services/templates/mongodb-native/controller-generator.ts +25 -5
  22. package/libs/instance-factories/services/templates/mongodb-native/step-conventions.ts +336 -68
  23. package/libs/instance-factories/services/templates/postgres-native/__tests__/controller-generator.test.ts +193 -0
  24. package/libs/instance-factories/services/templates/postgres-native/client-generator.ts +178 -0
  25. package/libs/instance-factories/services/templates/postgres-native/controller-generator.ts +372 -0
  26. package/libs/instance-factories/services/templates/postgres-native/ddl-generator.ts +236 -0
  27. package/libs/instance-factories/services/templates/postgres-native/service-generator.ts +84 -0
  28. package/libs/instance-factories/services/templates/postgres-native/step-conventions.ts +539 -0
  29. package/libs/instance-factories/services/templates/prisma/ai-behaviors-generator.ts +1 -1
  30. package/libs/instance-factories/services/templates/prisma/step-conventions.ts +21 -68
  31. package/package.json +3 -3
@@ -14,44 +14,123 @@
14
14
  * so step-emitted code compiles inline.
15
15
  */
16
16
 
17
- export interface MongoStepConvention {
18
- name: string;
19
- pattern: RegExp;
20
- generateCall: (match: RegExpMatchArray, ctx: MongoStepContext) => string;
21
- }
17
+ import {
18
+ toMethod,
19
+ toVar,
20
+ matchAgainstConventions,
21
+ type SharedConvention,
22
+ type SharedStepContext,
23
+ } from '../_shared/step-matching.js';
24
+
25
+ export type MongoStepConvention = SharedConvention<MongoStepContext>;
22
26
 
23
- export interface MongoStepContext {
24
- /** The model the action lives on (e.g. 'Player' for PlayerController). */
25
- modelName: string;
27
+ export interface MongoStepContext extends SharedStepContext {
26
28
  /** Collection name for the model (lowercased + 's' or storage.collection). */
27
29
  collectionName: string;
28
- serviceName: string;
29
- operationName: string;
30
- stepNum: number;
31
- /** Parameter names from the action (so we know what's in `args`). */
32
- parameterNames?: string[];
33
- /** Variables already declared (to avoid redeclaration). */
34
- declaredVars?: Set<string>;
35
- /** Named result variable from spec's `as:` clause. */
36
- resultName?: string;
30
+ /** The full model registry (name → ModelSpec) so conventions can read
31
+ * attributes for default values, types, and FK target collections. */
32
+ models?: Record<string, any>;
37
33
  }
38
34
 
39
- function toVar(name: string): string {
40
- return name.charAt(0).toLowerCase() + name.slice(1);
35
+ /**
36
+ * Compute sensible default values for a model's attributes — used by
37
+ * mechanical create/insertMany conventions to fill required fields the
38
+ * action's `args` doesn't supply.
39
+ *
40
+ * Defaults are derived from the spec's attribute declarations:
41
+ * - `default=<value>` annotation → that value (literal or expression)
42
+ * - `Integer required` with `min=N` → N
43
+ * - `Integer required` no min → 0
44
+ * - `String required` → '""' (empty)
45
+ * - `Boolean required` → false
46
+ * - `DateTime auto=now` or with `auto=now` → new Date().toISOString()
47
+ * - Foreign keys (fields ending in `Id` matching another model) → resolved
48
+ * from a declared variable of that type (e.g. `userId` ← `user._id`)
49
+ * - Anything optional or already present in args → omitted
50
+ *
51
+ * Returns an array of `key: expression` strings ready to inline into an
52
+ * object-literal record body. Empty if the model isn't in the registry.
53
+ */
54
+ export function deriveModelDefaults(
55
+ modelName: string,
56
+ ctx: MongoStepContext,
57
+ ): string[] {
58
+ if (!ctx.models) return [];
59
+ const model = ctx.models[modelName] || ctx.models[modelName.charAt(0).toUpperCase() + modelName.slice(1)];
60
+ if (!model) return [];
61
+ const attrs = model.attributes;
62
+ if (!attrs) return [];
63
+ const list = Array.isArray(attrs)
64
+ ? attrs.map((a: any) => [a.name, a])
65
+ : Object.entries(attrs);
66
+
67
+ const declaredVars = ctx.declaredVars || new Set();
68
+ const out: string[] = [];
69
+
70
+ // Names emitted explicitly by the convention's record-tail (createdAt /
71
+ // updatedAt) — skip in defaults to avoid TS1117 duplicate-property errors.
72
+ const conventionManaged = new Set(['createdAt', 'updatedAt', 'id']);
73
+ for (const [name, attr] of list as [string, any][]) {
74
+ if (!name) continue;
75
+ if (conventionManaged.has(name)) continue;
76
+ // Skip optional fields without explicit defaults
77
+ const required = !!attr.required;
78
+ const hasDefault = attr.default !== undefined;
79
+ if (!required && !hasDefault) continue;
80
+ // Skip if already supplied via spread args (caller overlays)
81
+ // — handled at convention call site by spreading args first.
82
+ if (hasDefault) {
83
+ out.push(`${name}: ${formatDefault(attr.default, attr.type)}`);
84
+ continue;
85
+ }
86
+ // Required field — derive a default from type
87
+ const type = (attr.type || 'String').toLowerCase();
88
+ if (type === 'integer' || type === 'int' || type === 'number' || type === 'float') {
89
+ const min = attr.min ?? 0;
90
+ out.push(`${name}: ${min}`);
91
+ } else if (type === 'boolean') {
92
+ out.push(`${name}: false`);
93
+ } else if (type === 'datetime' || type === 'date') {
94
+ if (attr.auto === 'now') {
95
+ out.push(`${name}: new Date().toISOString()`);
96
+ } else {
97
+ out.push(`${name}: new Date().toISOString()`);
98
+ }
99
+ } else {
100
+ // String / UUID / Text / etc. — try FK resolution first.
101
+ // Exclude self-reference (current model would FK-resolve to itself
102
+ // and emit a self-loop). The current model name's lowercase form
103
+ // is the conventionally-bound variable name.
104
+ const selfVar = modelName.charAt(0).toLowerCase() + modelName.slice(1);
105
+ const fkMatch = name.match(/^(.+)Id$/);
106
+ if (fkMatch && fkMatch[1] !== selfVar && declaredVars.has(fkMatch[1])) {
107
+ out.push(`${name}: (${fkMatch[1]} as any)?._id ?? (${fkMatch[1]} as any)?.id`);
108
+ } else {
109
+ out.push(`${name}: ''`);
110
+ }
111
+ }
112
+ }
113
+ return out;
41
114
  }
42
115
 
43
- function toCollection(modelName: string): string {
44
- return modelName.toLowerCase() + 's';
116
+ function formatDefault(value: any, type?: string): string {
117
+ if (value === null || value === undefined) return 'null';
118
+ if (typeof value === 'boolean') return String(value);
119
+ if (typeof value === 'number') return String(value);
120
+ if (typeof value === 'string') {
121
+ // Numeric defaults are sometimes parsed as strings
122
+ if (/^-?\d+(\.\d+)?$/.test(value)) return value;
123
+ if (value === 'true' || value === 'false') return value;
124
+ if (value === 'now' && (type === 'DateTime' || type === 'Date')) {
125
+ return 'new Date().toISOString()';
126
+ }
127
+ return JSON.stringify(value);
128
+ }
129
+ return JSON.stringify(value);
45
130
  }
46
131
 
47
- /** Camel-case a step's text into a TS identifier. Identical algorithm to
48
- * prisma's step-conventions `toMethod` so AI-behavior function names align
49
- * across the orm-agnostic generator. */
50
- function toMethod(words: string): string {
51
- const cleaned = words.trim().replace(/[^A-Za-z0-9\s]+/g, ' ');
52
- const camel = cleaned.replace(/\s+(.)/g, (_, c) => c.toUpperCase()).replace(/^\w/, (c) => c.toLowerCase());
53
- const safe = camel.replace(/[^A-Za-z0-9_$]/g, '');
54
- return safe || 'unnamedStep';
132
+ function toCollection(modelName: string): string {
133
+ return modelName.toLowerCase() + 's';
55
134
  }
56
135
 
57
136
  /** Resolve a value reference in step text to a TS expression. */
@@ -76,6 +155,20 @@ function resolveValue(rawValue: string, ctx: MongoStepContext): string {
76
155
  return `'${value.replace(/'/g, "\\'")}'`;
77
156
  }
78
157
 
158
+ /** Find the most-recent step{N}Result variable in declaredVars — used as
159
+ * the source for `Persist X` / `Save X` patterns where the previous step
160
+ * (an AI pure transform) computed the record to write. */
161
+ function mostRecentStepResult(ctx: MongoStepContext): string | null {
162
+ const declared = Array.from(ctx.declaredVars || []);
163
+ const stepResults = declared.filter((v) => /^step\d+Result$/.test(v))
164
+ .sort((a, b) => parseInt(b.slice(4), 10) - parseInt(a.slice(4), 10));
165
+ return stepResults[0] ?? null;
166
+ }
167
+
168
+ function pascal(model: string): string {
169
+ return model.charAt(0).toUpperCase() + model.slice(1);
170
+ }
171
+
79
172
  export const MONGO_STEP_CONVENTIONS: MongoStepConvention[] = [
80
173
  // --- Find / Lookup by single field ---
81
174
  // Matches: "Look up X by Y", "Find X by Y", "Find X by Y or fail with 404"
@@ -98,9 +191,14 @@ export const MONGO_STEP_CONVENTIONS: MongoStepConvention[] = [
98
191
  }
99
192
  declared.add(modelVar);
100
193
  const failOnMissing = /or\s+fail/i.test(m[0]);
194
+ // Emit as `let` so subsequent conditional-create steps can reassign
195
+ // the variable when the lookup returned null.
196
+ // Also surface `id` (string) alongside `_id` (ObjectId) for AI-body
197
+ // compatibility — LLMs trained on prisma routinely check `.id`.
101
198
  return ` // Step ${ctx.stepNum}: Find ${model} by ${field}
102
199
  const ${modelVar}_collection = await getCollection('${collection}');
103
- const ${modelVar} = await ${modelVar}_collection.findOne({ ${field}: ${idVal} });${failOnMissing ? `
200
+ let ${modelVar} = await ${modelVar}_collection.findOne({ ${field}: ${idVal} });
201
+ if (${modelVar} && !(${modelVar} as any).id && (${modelVar} as any)._id) (${modelVar} as any).id = String((${modelVar} as any)._id);${failOnMissing ? `
104
202
  if (!${modelVar}) throw new Error('${model} not found');` : ''}`;
105
203
  },
106
204
  },
@@ -121,9 +219,29 @@ export const MONGO_STEP_CONVENTIONS: MongoStepConvention[] = [
121
219
  return ` // Step ${ctx.stepNum}: Find ${model} by ${f1} and ${f2} (already loaded)`;
122
220
  }
123
221
  declared.add(modelVar);
222
+ // `let` for downstream conditional-create reassignment.
223
+ // Field-source resolution: a field like `userId` typically comes
224
+ // from a prior find/create (`user._id`), not from the action's
225
+ // request body. Check declaredVars first — if there's a model var
226
+ // matching the field's prefix (userId → user, gameId → game),
227
+ // use its `_id`. Otherwise fall back to args. Exclude the CURRENT
228
+ // model var (we're loading it now; it can't reference itself).
229
+ const resolveFieldSource = (f: string) => {
230
+ const stripIdMatch = f.match(/^(.+)Id$/);
231
+ if (stripIdMatch) {
232
+ const candidate = stripIdMatch[1];
233
+ if (candidate !== modelVar && declared.has(candidate)) {
234
+ return `(${candidate} as any)?._id ?? (${candidate} as any)?.id`;
235
+ }
236
+ }
237
+ return `args.${f}`;
238
+ };
239
+ const f1Src = resolveFieldSource(f1);
240
+ const f2Src = resolveFieldSource(f2);
124
241
  return ` // Step ${ctx.stepNum}: Find ${model} by ${f1} and ${f2}
125
242
  const ${modelVar}_collection = await getCollection('${collection}');
126
- const ${modelVar} = await ${modelVar}_collection.findOne({ ${f1}: args.${f1}, ${f2}: args.${f2} });`;
243
+ let ${modelVar} = await ${modelVar}_collection.findOne({ ${f1}: ${f1Src}, ${f2}: ${f2Src} });
244
+ if (${modelVar} && !(${modelVar} as any).id && (${modelVar} as any)._id) (${modelVar} as any).id = String((${modelVar} as any)._id);`;
127
245
  },
128
246
  },
129
247
 
@@ -290,6 +408,183 @@ export const MONGO_STEP_CONVENTIONS: MongoStepConvention[] = [
290
408
  },
291
409
  },
292
410
 
411
+ // ──────────────────────────────────────────────────────────────────
412
+ // SIDE-EFFECT CONVENTIONS — patterns that the generator handles
413
+ // mechanically. The LLM never sees these; persistence happens
414
+ // deterministically from the spec text.
415
+ // ──────────────────────────────────────────────────────────────────
416
+
417
+ // --- Persist / Save / Store {Model} record ---
418
+ // Matches: "Persist refresh token for revocation tracking",
419
+ // "Save user record", "Store session"
420
+ // Source of the record: most-recent step{N}Result if any (typical case
421
+ // — the prior AI step computed what to persist), else `args`.
422
+ {
423
+ name: 'persist',
424
+ pattern: /^(?:persist|save|store)\s+(\w+(?:\s+\w+)?)(?:\s+(?:for|to|record).*)?$/i,
425
+ generateCall: (m, ctx) => {
426
+ // m[1] may be "refresh token" — collapse to camelCase + collection
427
+ const target = toVar(m[1].replace(/\s+(.)/g, (_, c) => c.toUpperCase()));
428
+ const collection = toCollection(target);
429
+ const recordSrc = mostRecentStepResult(ctx) ?? 'args';
430
+ return ` // Step ${ctx.stepNum}: Persist ${m[1]}
431
+ {
432
+ const _coll = await getCollection('${collection}');
433
+ await _coll.insertOne(${recordSrc} && typeof ${recordSrc} === 'object' && !Array.isArray(${recordSrc}) ? ${recordSrc} as any : { value: ${recordSrc} });
434
+ }`;
435
+ },
436
+ },
437
+
438
+ // --- Conditional create: "If X does not exist, create new X with ..." ---
439
+ // Reuses the model variable already declared by a prior find; mutates
440
+ // it to point at the newly-created record so subsequent steps see it.
441
+ {
442
+ name: 'conditional-create',
443
+ pattern: /^if\s+(\w+)\s+does\s+not\s+exist,?\s+create\s+new\s+(\w+)(?:\s+with\s+.+)?$/i,
444
+ generateCall: (m, ctx) => {
445
+ const modelVar = toVar(m[1]);
446
+ const Model = pascal(m[2]);
447
+ const collection = toCollection(Model);
448
+ if (!ctx.declaredVars?.has(modelVar)) return ''; // need prior find
449
+ // Reassigns the original `let ${modelVar}` declared by find — so
450
+ // subsequent steps (AI or convention) see the newly-created record
451
+ // when the lookup originally returned null. Defaults come from the
452
+ // model spec (#43K-A): required fields not present in args get
453
+ // their type-default (level: 1, totalResources: '', etc.).
454
+ const defaults = deriveModelDefaults(Model, ctx);
455
+ const defaultsBlock = defaults.length > 0 ? defaults.join(', ') + ',' : '';
456
+ return ` // Step ${ctx.stepNum}: If ${modelVar} does not exist, create new ${Model}
457
+ if (!${modelVar}) {
458
+ const _newRecord = { ${defaultsBlock} ...args, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() };
459
+ const _ins = await (await getCollection('${collection}')).insertOne(_newRecord);
460
+ ${modelVar} = { _id: _ins.insertedId, id: String(_ins.insertedId), ..._newRecord } as any;
461
+ } else if (${modelVar} && !(${modelVar} as any).id && (${modelVar} as any)._id) {
462
+ (${modelVar} as any).id = String((${modelVar} as any)._id);
463
+ }`;
464
+ },
465
+ },
466
+
467
+ // --- Conditional update: "If X exists, update Y" ---
468
+ // Updates a single field on the previously-loaded record. Field defaults
469
+ // to lastLoginAt-style timestamp when the step says "update Y" without
470
+ // a "to <value>" clause.
471
+ {
472
+ name: 'conditional-update',
473
+ pattern: /^if\s+(\w+)\s+exists,?\s+update\s+(\w+)(?:\s+(.+))?$/i,
474
+ generateCall: (m, ctx) => {
475
+ const modelVar = toVar(m[1]);
476
+ const field = m[2];
477
+ const collection = toCollection(m[1]);
478
+ if (!ctx.declaredVars?.has(modelVar)) return '';
479
+ return ` // Step ${ctx.stepNum}: If ${modelVar} exists, update ${field}
480
+ if (${modelVar}) {
481
+ await (await getCollection('${collection}')).updateOne(
482
+ { _id: ${modelVar}._id },
483
+ { $set: { ${field}: new Date().toISOString() } }
484
+ );
485
+ }`;
486
+ },
487
+ },
488
+
489
+ // --- Auto-create / Bulk fan-out create ---
490
+ // Matches: "Auto-create player profiles for all available games"
491
+ // "Create X records for all Y"
492
+ // Pure-mechanical: enumerate the source collection, build a record per
493
+ // item linking it to the most-recently-loaded model var (typically
494
+ // `user`), insertMany. No AI hop — domain-specific field defaults are
495
+ // limited to the link fields (userId, gameId) plus createdAt/updatedAt.
496
+ // Anything richer should be done in a follow-up step (e.g. "Set displayName...").
497
+ {
498
+ name: 'auto-create-loop',
499
+ pattern: /^(?:auto-create|bulk\s+create)\s+(\w+)\s+(?:profile|record|entry|entries)?s?\s+for\s+(?:all\s+)?(?:available\s+)?(\w+)$/i,
500
+ generateCall: (m, ctx) => {
501
+ const Model = pascal(m[1]);
502
+ const targetCollection = toCollection(Model);
503
+ const sourceCollection = m[2].toLowerCase();
504
+ // Identify the "owner" var. Owner is the FIRST-declared non-stepResult
505
+ // var (e.g. `user`); spec authors load the owner before enumerating
506
+ // sources of its children.
507
+ const declared = Array.from(ctx.declaredVars || []);
508
+ const ownerVar = declared.find((v) => !/^step\d+Result$/.test(v) && v !== 'args');
509
+ const sourceSingular = sourceCollection.replace(/s$/, '');
510
+ const linkField = sourceSingular + 'Id';
511
+ const ownerLinkField = ownerVar ? ownerVar + 'Id' : 'ownerId';
512
+
513
+ // Pull required-field defaults from the target model spec (#43K-A).
514
+ // Conventions can now read model attributes via ctx.models, so a
515
+ // Player record gets `level: 1, experience: 0, totalResources: '""'`
516
+ // automatically — no AI hop, no missing-required-field errors.
517
+ const modelDefaults = deriveModelDefaults(Model, ctx);
518
+ const defaultsBlock = modelDefaults.length > 0
519
+ ? modelDefaults.filter((d) => !d.startsWith(`${ownerLinkField}:`) && !d.startsWith(`${linkField}:`)).join(', ')
520
+ : '';
521
+
522
+ return ` // Step ${ctx.stepNum}: Auto-create ${m[1]} ${m[2]} for all ${m[2]}
523
+ {
524
+ const _allItems = await (await getCollection('${sourceCollection}')).find({}).toArray();
525
+ const _ownerId = ${ownerVar ? `(${ownerVar} as any)?.id ?? (${ownerVar} as any)?._id` : 'null'};
526
+ const _records = _allItems.map((_item: any) => ({
527
+ ${defaultsBlock ? defaultsBlock + ',' : ''}
528
+ ${ownerLinkField}: _ownerId,
529
+ ${linkField}: (_item as any).${sourceSingular}Id ?? (_item as any).id ?? String((_item as any)._id),
530
+ createdAt: new Date().toISOString(),
531
+ updatedAt: new Date().toISOString(),
532
+ }));
533
+ if (_records.length > 0) {
534
+ await (await getCollection('${targetCollection}')).insertMany(_records as any);
535
+ }
536
+ }`;
537
+ },
538
+ },
539
+
540
+ // --- "Otherwise create new X record" — pairs with prior conditional ---
541
+ // Emitted as an `else` branch attached to the conditional that came
542
+ // before. We don't enforce ordering at the convention level; if the
543
+ // author writes "Otherwise" without a prior "If ... does not exist,
544
+ // create" the emitted else lands without an if and tsc catches it.
545
+ {
546
+ name: 'otherwise-create',
547
+ pattern: /^otherwise\s+create\s+(?:new\s+)?(\w+)\s+record$/i,
548
+ generateCall: (m, ctx) => {
549
+ const Model = pascal(m[1]);
550
+ const modelVar = toVar(Model);
551
+ const collection = toCollection(Model);
552
+ // The model var was declared (as `let`) by the find that paired
553
+ // with the prior conditional-update; reassign it here so downstream
554
+ // steps see the new record.
555
+ const wasDeclared = ctx.declaredVars?.has(modelVar);
556
+ const declared = Array.from(ctx.declaredVars || []);
557
+ // Model-driven defaults from the spec — these may include FK fields
558
+ // (userId, gameId) that we'd ALSO derive from declaredVars below.
559
+ const defaults = deriveModelDefaults(Model, ctx);
560
+ // Track names already supplied by defaults so we don't double-emit.
561
+ const supplied = new Set<string>();
562
+ for (const entry of defaults) {
563
+ const colonIdx = entry.indexOf(':');
564
+ if (colonIdx > 0) supplied.add(entry.slice(0, colonIdx).trim());
565
+ }
566
+ // FK assignments from prior-declared model vars — only emit those
567
+ // not already provided by spec defaults (avoids TS1117 dup-keys).
568
+ const fkAssignments = declared
569
+ .filter((v) => v !== modelVar && !/^step\d+Result$/.test(v) && v !== 'args')
570
+ .filter((v) => !supplied.has(v + 'Id'))
571
+ .map((v) => {
572
+ supplied.add(v + 'Id');
573
+ return `${v}Id: (${v} as any)?._id ?? (${v} as any)?.id`;
574
+ })
575
+ .join(', ');
576
+ const defaultsBlock = defaults.length > 0 ? defaults.join(', ') + ',' : '';
577
+ ctx.declaredVars?.add(modelVar);
578
+ return ` // Step ${ctx.stepNum}: Otherwise create new ${Model} record
579
+ else {
580
+ const _newRecord = { ${defaultsBlock} ${fkAssignments ? fkAssignments + ',' : ''} ...args, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() };
581
+ const _ins = await (await getCollection('${collection}')).insertOne(_newRecord);
582
+ ${wasDeclared ? `${modelVar} = { _id: _ins.insertedId, id: String(_ins.insertedId), ..._newRecord } as any;` : `const ${modelVar} = { _id: _ins.insertedId, id: String(_ins.insertedId), ..._newRecord };
583
+ void ${modelVar};`}
584
+ }`;
585
+ },
586
+ },
587
+
293
588
  // --- Send/Emit/Publish event ---
294
589
  // Emits an eventBus.publish call. The payload references the controller's
295
590
  // primary model variable IF it was declared by a prior matched step;
@@ -383,41 +678,14 @@ export function matchMongoStep(
383
678
  inputs?: string[];
384
679
  resultVar?: string;
385
680
  } {
386
- for (const convention of MONGO_STEP_CONVENTIONS) {
387
- const m = step.match(convention.pattern);
388
- if (m) {
389
- const call = convention.generateCall(m, ctx);
390
- // A convention may signal "regex matched but I can't safely emit"
391
- // by returning the empty string (e.g. update-field needs the model
392
- // to be declared by a prior find; if it isn't, fall through to AI).
393
- if (call) {
394
- return { matched: true, call };
395
- }
396
- }
397
- }
398
-
399
- // Unmatched — caller is expected to delegate to an AI-generated pure
400
- // function in `behaviors/<Owner>.ai.ts`. Inputs = action parameters +
401
- // any variables already declared by prior matched conventions; this
402
- // matches what the AI-behaviors-generator will produce for the same
403
- // step (it walks the same conventions in the same order, so its
404
- // declaredVars set agrees with ours by construction).
405
- const functionName = toMethod(step);
406
- const declared = Array.from(ctx.declaredVars || []);
407
- const paramNames = ctx.parameterNames || [];
408
- const inputs = [...paramNames, ...declared];
409
- const resultVar = ctx.resultName || `step${ctx.stepNum}Result`;
410
- if (ctx.declaredVars) ctx.declaredVars.add(resultVar);
411
- const inputObj = inputs.length > 0
412
- ? `{ ${inputs.map((n) => paramNames.includes(n) ? `${n}: args.${n}` : n).join(', ')} }`
413
- : '{}';
414
-
415
- return {
416
- matched: false,
417
- call: ` // Step ${ctx.stepNum}: ${step} [AI-generated — pure function]
418
- const ${resultVar} = await aiBehaviors.${functionName}(${inputObj});`,
419
- functionName,
420
- inputs,
421
- resultVar,
422
- };
681
+ // Mongo's AI-fallback uses `args.X` for parameter inputs because the
682
+ // mongo-native controller-generator wraps the action body with a single
683
+ // `args` parameter (e.g. `register(args)`) and indexes through it
684
+ // rather than destructuring at function entry. Declared variables stay
685
+ // bare (they were declared inside the body by previous steps).
686
+ const aiArgsExpr = (inputs: string[], paramNames: string[]) =>
687
+ inputs.length > 0
688
+ ? `{ ${inputs.map((n) => paramNames.includes(n) ? `${n}: args.${n}` : n).join(', ')} }`
689
+ : '{}';
690
+ return matchAgainstConventions(step, ctx, MONGO_STEP_CONVENTIONS, aiArgsExpr);
423
691
  }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Regression tests for the PostgreSQL native (pg) controller generator.
3
+ *
4
+ * Mirrors the mongodb-native suite — the conventions are intentionally
5
+ * the same shape so spec authors can swap factories without rewriting
6
+ * .specly text. These tests pin shape, not full compilation:
7
+ *
8
+ * - CURVED ops emit pg helper calls (insertOne / findOneByField /
9
+ * updateOneById / deleteOneById / findAll / query)
10
+ * - Lifecycle transitions decode flow shorthand and emit a transition map
11
+ * - Behaviour-derived actions colliding with CRUD names emit a warning
12
+ * and are dropped (shared with mongo)
13
+ * - Custom action steps run through `matchPgStep` (CRUD-shape steps
14
+ * map to inline helper calls, the rest delegate to aiBehaviors)
15
+ */
16
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
17
+ import generatePgNativeController from '../controller-generator.js';
18
+
19
+ const baseModel = {
20
+ name: 'Todo',
21
+ attributes: {
22
+ id: { name: 'id', type: 'UUID', required: true, auto: 'uuid4' },
23
+ title: { name: 'title', type: 'String', required: true },
24
+ completed: { name: 'completed', type: 'Boolean', default: false },
25
+ },
26
+ };
27
+
28
+ const baseController = {
29
+ name: 'TodoController',
30
+ description: 'Controller for Todo',
31
+ cured: { create: true, retrieve: true, update: true, evolve: true, delete: true },
32
+ };
33
+
34
+ describe('Postgres native — controller-generator', () => {
35
+ it('emits all CURVED ops via pgClient helpers', () => {
36
+ const out = generatePgNativeController({
37
+ controller: baseController,
38
+ model: baseModel,
39
+ } as any);
40
+ expect(out).toContain(`TABLE_NAME = 'todos'`);
41
+ expect(out).toContain('insertOne');
42
+ expect(out).toContain('findOneByField');
43
+ expect(out).toContain('updateOneById');
44
+ expect(out).toContain('deleteOneById');
45
+ expect(out).toContain('findAll');
46
+ expect(out).not.toContain('PrismaClient');
47
+ expect(out).not.toContain('prisma.');
48
+ expect(out).not.toContain('getCollection');
49
+ });
50
+
51
+ it('does NOT import ObjectId — pg uses string ids end-to-end', () => {
52
+ const out = generatePgNativeController({
53
+ controller: baseController,
54
+ model: baseModel,
55
+ } as any);
56
+ expect(out).not.toContain('ObjectId');
57
+ expect(out).not.toContain('mongodb');
58
+ });
59
+
60
+ it('emits lifecycle transition map for `flow:` shorthand', () => {
61
+ const out = generatePgNativeController({
62
+ controller: baseController,
63
+ model: {
64
+ ...baseModel,
65
+ lifecycles: { status: { name: 'status', flow: 'pending -> done' } },
66
+ },
67
+ } as any);
68
+ expect(out).toContain('public async evolve(');
69
+ expect(out).toContain('"pending":["done"]');
70
+ expect(out).toContain('Invalid transition');
71
+ });
72
+
73
+ it('emits a working multi-state transition map for explicit states', () => {
74
+ const out = generatePgNativeController({
75
+ controller: baseController,
76
+ model: {
77
+ ...baseModel,
78
+ lifecycles: {
79
+ status: {
80
+ name: 'status',
81
+ states: ['draft', 'open', 'closed'],
82
+ transitions: { open: 'draft -> open', close: 'open -> closed' },
83
+ },
84
+ },
85
+ },
86
+ } as any);
87
+ expect(out).toContain('"draft":["open"]');
88
+ expect(out).toContain('"open":["closed"]');
89
+ });
90
+
91
+ it('uses model.storage.table override when present', () => {
92
+ const out = generatePgNativeController({
93
+ controller: baseController,
94
+ model: { ...baseModel, storage: { table: 'custom_todos' } },
95
+ } as any);
96
+ expect(out).toContain(`TABLE_NAME = 'custom_todos'`);
97
+ expect(out).toContain('TABLE_NAME');
98
+ });
99
+
100
+ it('omits CURVED ops not declared on the controller', () => {
101
+ const out = generatePgNativeController({
102
+ controller: { ...baseController, cured: { retrieve: true } },
103
+ model: baseModel,
104
+ } as any);
105
+ expect(out).toContain('public async retrieve(');
106
+ expect(out).not.toContain('public async create(');
107
+ expect(out).not.toContain('public async update(');
108
+ expect(out).not.toContain('public async delete(');
109
+ expect(out).not.toContain('public async evolve(');
110
+ });
111
+
112
+ describe('behaviour-derived actions', () => {
113
+ let warnSpy: ReturnType<typeof vi.spyOn>;
114
+ beforeEach(() => {
115
+ warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
116
+ });
117
+ afterEach(() => {
118
+ warnSpy.mockRestore();
119
+ });
120
+
121
+ it('warns + drops behaviour-derived actions colliding with CRUD names', () => {
122
+ const out = generatePgNativeController({
123
+ controller: {
124
+ ...baseController,
125
+ actions: {
126
+ delete: {
127
+ description: 'Soft-delete a todo',
128
+ parameters: { id: 'String' },
129
+ steps: ['Set deletedAt to now'],
130
+ },
131
+ },
132
+ },
133
+ model: baseModel,
134
+ } as any);
135
+ // The custom delete should NOT be emitted (collides with CRUD delete).
136
+ // Generator emits exactly one `public async delete(` (the CRUD op).
137
+ const deleteMatches = out.match(/public async delete\(/g) || [];
138
+ expect(deleteMatches.length).toBe(1);
139
+ expect(warnSpy).toHaveBeenCalledTimes(1);
140
+ const warnMsg = String(warnSpy.mock.calls[0]?.[0] ?? '');
141
+ expect(warnMsg).toContain('TodoController.delete');
142
+ expect(warnMsg).toContain('collides');
143
+ });
144
+
145
+ it('runs steps through matchPgStep — CRUD-shape steps inline pg helpers', () => {
146
+ const out = generatePgNativeController({
147
+ controller: {
148
+ ...baseController,
149
+ actions: {
150
+ archive: {
151
+ description: 'Archive a todo by id',
152
+ parameters: { id: 'String' },
153
+ steps: [
154
+ 'Find Todo by id or fail with 404',
155
+ 'Update Todo completed to true',
156
+ 'Emit TodoArchived event',
157
+ ],
158
+ },
159
+ },
160
+ },
161
+ model: baseModel,
162
+ } as any);
163
+ expect(out).toContain('public async archive(');
164
+ // Step 1: find-by-field convention
165
+ expect(out).toContain(`findOneByField('todos', 'id', args.id)`);
166
+ // Step 2: update-field convention
167
+ expect(out).toContain(`updateOneById('todos', todo.id, { completed: true })`);
168
+ // Step 3: send-event convention
169
+ expect(out).toContain(`eventBus.publish('TodoArchived'`);
170
+ });
171
+
172
+ it('falls back to AI-behaviors for unmatched steps', () => {
173
+ const out = generatePgNativeController({
174
+ controller: {
175
+ ...baseController,
176
+ actions: {
177
+ score: {
178
+ description: 'Compute a todo priority score',
179
+ parameters: { id: 'String' },
180
+ steps: [
181
+ 'Compute priority score from urgency and effort',
182
+ 'Return score',
183
+ ],
184
+ },
185
+ },
186
+ },
187
+ model: baseModel,
188
+ } as any);
189
+ expect(out).toContain(`aiBehaviors.computePriorityScoreFromUrgencyAndEffort`);
190
+ expect(out).toContain(`from '../behaviors/TodoController.ai.js'`);
191
+ });
192
+ });
193
+ });