@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.
- package/dist/ai/behavior-ai-service.js +2 -2
- package/dist/ai/behavior-ai-service.js.map +1 -1
- package/dist/libs/instance-factories/applications/templates/generic/backend-package-json-generator.js +22 -5
- 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 +18 -6
- package/dist/libs/instance-factories/services/templates/mongodb-native/step-conventions.js +230 -34
- 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 +1 -1
- package/dist/libs/instance-factories/services/templates/prisma/step-conventions.js +7 -34
- package/dist/realize/index.d.ts.map +1 -1
- package/dist/realize/index.js +8 -0
- 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/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 +25 -5
- package/libs/instance-factories/services/templates/mongodb-native/step-conventions.ts +336 -68
- 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 +1 -1
- package/libs/instance-factories/services/templates/prisma/step-conventions.ts +21 -68
- package/package.json +3 -3
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Postgres native (pg) — pool singleton.
|
|
3
|
+
*
|
|
4
|
+
* Emits a small connection module shared by all controllers and services.
|
|
5
|
+
* Table-naming convention: lowercase + 's' suffix (User → users), matching
|
|
6
|
+
* the lowercase-pluralized scheme declared in the factory yaml. Models with
|
|
7
|
+
* custom table names override via `model.storage?.table` (the
|
|
8
|
+
* controller-generator looks there first).
|
|
9
|
+
*
|
|
10
|
+
* Connection priority: POSTGRES_URL > DATABASE_URL > localhost default.
|
|
11
|
+
* Both URL forms are widely accepted by hosting providers (Supabase, Neon,
|
|
12
|
+
* Railway, Render). The pool is lazily initialised on first use and reused;
|
|
13
|
+
* `disconnect` is exposed for graceful-shutdown wiring.
|
|
14
|
+
*/
|
|
15
|
+
import type { TemplateContext } from '@specverse/types';
|
|
16
|
+
|
|
17
|
+
export default function generatePgClient(_context: TemplateContext): string {
|
|
18
|
+
return `/**
|
|
19
|
+
* Postgres native (pg) — singleton pool + helpers.
|
|
20
|
+
*
|
|
21
|
+
* Picks up POSTGRES_URL or DATABASE_URL from the environment. The pool is
|
|
22
|
+
* lazily initialised on first use and reused across requests; \`disconnect\`
|
|
23
|
+
* is exposed for graceful-shutdown wiring. \`withTx\` runs a callback in a
|
|
24
|
+
* single connection inside BEGIN/COMMIT (rolled back on throw).
|
|
25
|
+
*/
|
|
26
|
+
import { Pool, type PoolClient, type QueryResult, type QueryResultRow } from 'pg';
|
|
27
|
+
|
|
28
|
+
const connectionString =
|
|
29
|
+
process.env.POSTGRES_URL ||
|
|
30
|
+
process.env.DATABASE_URL ||
|
|
31
|
+
'postgres://postgres:postgres@localhost:5432/specverse';
|
|
32
|
+
|
|
33
|
+
let pool: Pool | null = null;
|
|
34
|
+
|
|
35
|
+
export function getPool(): Pool {
|
|
36
|
+
if (pool) return pool;
|
|
37
|
+
pool = new Pool({ connectionString });
|
|
38
|
+
return pool;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Run a parameterised query against the pool.
|
|
42
|
+
* T defaults to QueryResultRow so callers can pass a row interface. */
|
|
43
|
+
export async function query<T extends QueryResultRow = QueryResultRow>(
|
|
44
|
+
text: string,
|
|
45
|
+
params: ReadonlyArray<unknown> = [],
|
|
46
|
+
): Promise<QueryResult<T>> {
|
|
47
|
+
return getPool().query<T>(text, params as unknown[]);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Run a callback inside a transaction. The callback receives a dedicated
|
|
51
|
+
* client; commit on resolve, rollback on throw. */
|
|
52
|
+
export async function withTx<T>(
|
|
53
|
+
fn: (client: PoolClient) => Promise<T>,
|
|
54
|
+
): Promise<T> {
|
|
55
|
+
const client = await getPool().connect();
|
|
56
|
+
try {
|
|
57
|
+
await client.query('BEGIN');
|
|
58
|
+
const result = await fn(client);
|
|
59
|
+
await client.query('COMMIT');
|
|
60
|
+
return result;
|
|
61
|
+
} catch (err) {
|
|
62
|
+
await client.query('ROLLBACK');
|
|
63
|
+
throw err;
|
|
64
|
+
} finally {
|
|
65
|
+
client.release();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Build a comma-separated list of \`"col"\` from an object's keys, plus
|
|
70
|
+
* matching positional placeholders \`$1, $2, ...\`. Quoted to preserve
|
|
71
|
+
* case-sensitive identifiers (camelCase columns work without folding). */
|
|
72
|
+
function buildPositionals(keys: string[], offset = 0): string {
|
|
73
|
+
return keys.map((_k, i) => '$' + (i + 1 + offset)).join(', ');
|
|
74
|
+
}
|
|
75
|
+
function quoteCols(keys: string[]): string {
|
|
76
|
+
return keys.map((k) => '"' + k.replace(/"/g, '""') + '"').join(', ');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** INSERT a record into \`table\` and return the inserted row (with any
|
|
80
|
+
* database-supplied defaults like generated id / createdAt populated). */
|
|
81
|
+
export async function insertOne<T extends QueryResultRow = QueryResultRow>(
|
|
82
|
+
table: string,
|
|
83
|
+
record: Record<string, unknown>,
|
|
84
|
+
): Promise<T> {
|
|
85
|
+
const keys = Object.keys(record);
|
|
86
|
+
const values = keys.map((k) => record[k]);
|
|
87
|
+
const sql = keys.length === 0
|
|
88
|
+
? \`INSERT INTO "\${table}" DEFAULT VALUES RETURNING *\`
|
|
89
|
+
: \`INSERT INTO "\${table}" (\${quoteCols(keys)}) VALUES (\${buildPositionals(keys)}) RETURNING *\`;
|
|
90
|
+
const result = await query<T>(sql, values);
|
|
91
|
+
return result.rows[0] as T;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Bulk-insert many records into \`table\` in one round trip. Returns the
|
|
95
|
+
* inserted rows. All records must share the same shape. */
|
|
96
|
+
export async function insertMany<T extends QueryResultRow = QueryResultRow>(
|
|
97
|
+
table: string,
|
|
98
|
+
records: ReadonlyArray<Record<string, unknown>>,
|
|
99
|
+
): Promise<T[]> {
|
|
100
|
+
if (records.length === 0) return [];
|
|
101
|
+
const firstKeys = Object.keys(records[0]!);
|
|
102
|
+
const valuesClauses: string[] = [];
|
|
103
|
+
const flatValues: unknown[] = [];
|
|
104
|
+
for (let i = 0; i < records.length; i++) {
|
|
105
|
+
const row = records[i] as Record<string, unknown>;
|
|
106
|
+
const placeholders = firstKeys.map((_k, j) => '$' + (i * firstKeys.length + j + 1)).join(', ');
|
|
107
|
+
valuesClauses.push('(' + placeholders + ')');
|
|
108
|
+
for (const k of firstKeys) flatValues.push(row[k]);
|
|
109
|
+
}
|
|
110
|
+
const sql = \`INSERT INTO "\${table}" (\${quoteCols(firstKeys)}) VALUES \${valuesClauses.join(', ')} RETURNING *\`;
|
|
111
|
+
const result = await query<T>(sql, flatValues);
|
|
112
|
+
return result.rows;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** SELECT a single row by a field equality. Returns null if not found. */
|
|
116
|
+
export async function findOneByField<T extends QueryResultRow = QueryResultRow>(
|
|
117
|
+
table: string,
|
|
118
|
+
field: string,
|
|
119
|
+
value: unknown,
|
|
120
|
+
): Promise<T | null> {
|
|
121
|
+
const sql = \`SELECT * FROM "\${table}" WHERE "\${field.replace(/"/g, '""')}" = $1 LIMIT 1\`;
|
|
122
|
+
const result = await query<T>(sql, [value]);
|
|
123
|
+
return result.rows[0] ?? null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** SELECT a single row matching all (field, value) pairs (AND-ed). */
|
|
127
|
+
export async function findOneByFields<T extends QueryResultRow = QueryResultRow>(
|
|
128
|
+
table: string,
|
|
129
|
+
fields: Record<string, unknown>,
|
|
130
|
+
): Promise<T | null> {
|
|
131
|
+
const keys = Object.keys(fields);
|
|
132
|
+
if (keys.length === 0) {
|
|
133
|
+
const result = await query<T>(\`SELECT * FROM "\${table}" LIMIT 1\`);
|
|
134
|
+
return result.rows[0] ?? null;
|
|
135
|
+
}
|
|
136
|
+
const where = keys.map((k, i) => '"' + k.replace(/"/g, '""') + '" = $' + (i + 1)).join(' AND ');
|
|
137
|
+
const sql = \`SELECT * FROM "\${table}" WHERE \${where} LIMIT 1\`;
|
|
138
|
+
const result = await query<T>(sql, keys.map((k) => fields[k]));
|
|
139
|
+
return result.rows[0] ?? null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** SELECT all rows from a table — equivalent to a Mongo
|
|
143
|
+
* \`collection.find({}).toArray()\` for the bulk-fan-out auto-create
|
|
144
|
+
* convention. */
|
|
145
|
+
export async function findAll<T extends QueryResultRow = QueryResultRow>(
|
|
146
|
+
table: string,
|
|
147
|
+
): Promise<T[]> {
|
|
148
|
+
const result = await query<T>(\`SELECT * FROM "\${table}"\`);
|
|
149
|
+
return result.rows;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** UPDATE columns of the row identified by id. Quotes column names so
|
|
153
|
+
* camelCase identifiers survive postgres' default lower-case folding. */
|
|
154
|
+
export async function updateOneById(
|
|
155
|
+
table: string,
|
|
156
|
+
id: unknown,
|
|
157
|
+
patch: Record<string, unknown>,
|
|
158
|
+
): Promise<void> {
|
|
159
|
+
const keys = Object.keys(patch);
|
|
160
|
+
if (keys.length === 0) return;
|
|
161
|
+
const setClause = keys.map((k, i) => '"' + k.replace(/"/g, '""') + '" = $' + (i + 1)).join(', ');
|
|
162
|
+
const sql = \`UPDATE "\${table}" SET \${setClause} WHERE "id" = $\${keys.length + 1}\`;
|
|
163
|
+
await query(sql, [...keys.map((k) => patch[k]), id]);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** DELETE the row identified by id. */
|
|
167
|
+
export async function deleteOneById(table: string, id: unknown): Promise<void> {
|
|
168
|
+
await query(\`DELETE FROM "\${table}" WHERE "id" = $1\`, [id]);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function disconnect(): Promise<void> {
|
|
172
|
+
if (pool) {
|
|
173
|
+
await pool.end();
|
|
174
|
+
pool = null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
`;
|
|
178
|
+
}
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostgreSQL Native (pg) — Controller Generator
|
|
3
|
+
*
|
|
4
|
+
* Generates per-model controllers exposing CURVED operations via the pg
|
|
5
|
+
* pool helpers (`insertOne` / `findOneByField` / `updateOneById` / etc.).
|
|
6
|
+
* Same shape and same behaviour as the mongodb-native counterpart — the
|
|
7
|
+
* only differences are runtime-driver calls.
|
|
8
|
+
*
|
|
9
|
+
* Scope (MVP):
|
|
10
|
+
* - Validate / Create / Retrieve / Update / Delete fully wired
|
|
11
|
+
* - Evolve emits a lifecycle-aware update when the model declares states;
|
|
12
|
+
* transitions are validated against the declared flow before update
|
|
13
|
+
* - Custom actions emit per-step bodies via `matchPgStep`. The same
|
|
14
|
+
* matcher is passed to the AI-behaviors-generator (via realize/index.ts)
|
|
15
|
+
* so both sides agree on `declaredVars`, function names, and inputs.
|
|
16
|
+
*
|
|
17
|
+
* Out of scope:
|
|
18
|
+
* - withTx() multi-statement transactions for create/update flows
|
|
19
|
+
* (deferred — current MVP commits each helper call individually)
|
|
20
|
+
* - JOIN-driven retrieve variants (deferred)
|
|
21
|
+
*
|
|
22
|
+
* Table naming: lowercase + 's' (User → users) unless the model declares
|
|
23
|
+
* `storage.table`. ID is a string at the API surface; the pgClient helpers
|
|
24
|
+
* pass the value through as-is so UUIDs / slugs / int-as-string all work.
|
|
25
|
+
*/
|
|
26
|
+
import type { TemplateContext } from '@specverse/types';
|
|
27
|
+
import { buildTransitionMap, isAutoField } from '@specverse/types/spec-rules';
|
|
28
|
+
|
|
29
|
+
export default function generatePgNativeController(context: TemplateContext): string {
|
|
30
|
+
const { controller, model, models } = context as any;
|
|
31
|
+
if (!controller) throw new Error('Controller is required in template context');
|
|
32
|
+
if (!model) throw new Error('Model is required for controller generation');
|
|
33
|
+
|
|
34
|
+
const controllerName = controller.name;
|
|
35
|
+
const modelName = model.name;
|
|
36
|
+
const modelVar = lowerFirst(modelName);
|
|
37
|
+
const table = tableName(model);
|
|
38
|
+
const curedOps = controller.cured || {};
|
|
39
|
+
|
|
40
|
+
// Build a name → ModelSpec registry once so step-conventions can read
|
|
41
|
+
// attribute defaults (level: 1, totalResources: '', etc.) and FK targets
|
|
42
|
+
// when emitting create/insertMany code.
|
|
43
|
+
const modelRegistry: Record<string, any> = {};
|
|
44
|
+
if (Array.isArray(models)) {
|
|
45
|
+
for (const m of models) if (m?.name) modelRegistry[m.name] = m;
|
|
46
|
+
} else if (models && typeof models === 'object') {
|
|
47
|
+
Object.assign(modelRegistry, models);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const customActions = generateCustomActions(controller, modelRegistry);
|
|
51
|
+
|
|
52
|
+
const validate = generateValidateMethod(model, modelName);
|
|
53
|
+
const create = curedOps.create ? generateCreateMethod(modelName, modelVar) : '';
|
|
54
|
+
const retrieve = curedOps.retrieve ? generateRetrieveMethod(modelName, modelVar) : '';
|
|
55
|
+
const update = curedOps.update ? generateUpdateMethod(modelName, modelVar) : '';
|
|
56
|
+
const evolve = curedOps.evolve ? generateEvolveMethod(model, modelName, modelVar) : '';
|
|
57
|
+
const del = curedOps.delete ? generateDeleteMethod(modelName, modelVar) : '';
|
|
58
|
+
|
|
59
|
+
const hasEventPublishing =
|
|
60
|
+
curedOps.create || curedOps.update || curedOps.evolve || curedOps.delete;
|
|
61
|
+
|
|
62
|
+
// Helpers always imported: insertOne / findOneByField / findAll /
|
|
63
|
+
// updateOneById / deleteOneById are referenced by the CRUD ops; `query`
|
|
64
|
+
// is referenced by retrieveAll's offset path. The composite + bulk
|
|
65
|
+
// helpers are conditional — only the convention-driven step bodies
|
|
66
|
+
// emit them, so we scan the customActions.code to decide.
|
|
67
|
+
const helperImports = [
|
|
68
|
+
'insertOne',
|
|
69
|
+
'findOneByField',
|
|
70
|
+
'findAll',
|
|
71
|
+
'updateOneById',
|
|
72
|
+
'deleteOneById',
|
|
73
|
+
'query',
|
|
74
|
+
customActions.code.includes('findOneByFields(') ? 'findOneByFields' : '',
|
|
75
|
+
customActions.code.includes('insertMany(') ? 'insertMany' : '',
|
|
76
|
+
].filter(Boolean).join(', ');
|
|
77
|
+
|
|
78
|
+
return `/**
|
|
79
|
+
* ${controllerName}
|
|
80
|
+
* Model-specific business logic for ${modelName} (PostgreSQL native driver)
|
|
81
|
+
* ${controller.description || ''}
|
|
82
|
+
*/
|
|
83
|
+
import { ${helperImports} } from '../db/pgClient.js';
|
|
84
|
+
${hasEventPublishing || customActions.needsAiBehaviors ? `import { eventBus } from '../events/eventBus.js';` : ''}
|
|
85
|
+
${customActions.needsAiBehaviors ? `import * as aiBehaviors from '../behaviors/${controllerName}.ai.js';` : ''}
|
|
86
|
+
|
|
87
|
+
const TABLE_NAME = '${table}';
|
|
88
|
+
|
|
89
|
+
export class ${controllerName} {
|
|
90
|
+
${validate}
|
|
91
|
+
${create}
|
|
92
|
+
${retrieve}
|
|
93
|
+
${update}
|
|
94
|
+
${evolve}
|
|
95
|
+
${del}
|
|
96
|
+
${customActions.code}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const ${modelVar}Controller = new ${controllerName}();
|
|
100
|
+
export default ${modelVar}Controller;
|
|
101
|
+
`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function lowerFirst(s: string): string {
|
|
105
|
+
return s.charAt(0).toLowerCase() + s.slice(1);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function tableName(model: any): string {
|
|
109
|
+
if (model?.storage?.table) return String(model.storage.table);
|
|
110
|
+
// Lowercase-pluralized: User → users, OrderItem → orderitems
|
|
111
|
+
return model.name.toLowerCase() + 's';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function generateValidateMethod(model: any, modelName: string): string {
|
|
115
|
+
return `
|
|
116
|
+
/**
|
|
117
|
+
* Validate ${modelName} data — runs before create / update / evolve.
|
|
118
|
+
*/
|
|
119
|
+
public validate(
|
|
120
|
+
_data: any,
|
|
121
|
+
_context: { operation: 'create' | 'update' | 'evolve' }
|
|
122
|
+
): { valid: boolean; errors: string[] } {
|
|
123
|
+
const errors: string[] = [];
|
|
124
|
+
${generateValidationLogic(model)}
|
|
125
|
+
return { valid: errors.length === 0, errors };
|
|
126
|
+
}
|
|
127
|
+
`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function generateValidationLogic(model: any): string {
|
|
131
|
+
if (!model.attributes) return ' // No validation rules defined';
|
|
132
|
+
const attrList = Array.isArray(model.attributes)
|
|
133
|
+
? model.attributes.map((a: any) => [a.name, a])
|
|
134
|
+
: Object.entries(model.attributes);
|
|
135
|
+
const out: string[] = [];
|
|
136
|
+
attrList.forEach(([name, attr]: [string, any]) => {
|
|
137
|
+
if (attr.required && !isAutoField(name, attr)) {
|
|
138
|
+
out.push(` if (_context.operation === 'create' && !_data.${name}) errors.push('${name} is required');`);
|
|
139
|
+
}
|
|
140
|
+
if (attr.type === 'String' || attr.type === 'string') {
|
|
141
|
+
if (attr.min) out.push(` if (_data.${name} && _data.${name}.length < ${attr.min}) errors.push('${name} must be at least ${attr.min} characters');`);
|
|
142
|
+
if (attr.max) out.push(` if (_data.${name} && _data.${name}.length > ${attr.max}) errors.push('${name} must be at most ${attr.max} characters');`);
|
|
143
|
+
}
|
|
144
|
+
if (attr.values && Array.isArray(attr.values)) {
|
|
145
|
+
const values = attr.values.map((v: string) => `'${v}'`).join(', ');
|
|
146
|
+
out.push(` if (_data.${name} && ![${values}].includes(_data.${name})) errors.push('${name} must be one of: ${attr.values.join(', ')}');`);
|
|
147
|
+
}
|
|
148
|
+
if (attr.format === 'email') {
|
|
149
|
+
out.push(` if (_data.${name} && !/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(_data.${name})) errors.push('${name} must be a valid email address');`);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
return out.join('\n') || ' // No validation rules defined';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function generateCreateMethod(modelName: string, modelVar: string): string {
|
|
156
|
+
return `
|
|
157
|
+
/**
|
|
158
|
+
* Create a new ${modelName}.
|
|
159
|
+
*/
|
|
160
|
+
public async create(data: any): Promise<any> {
|
|
161
|
+
const validation = this.validate(data, { operation: 'create' });
|
|
162
|
+
if (!validation.valid) throw new Error(\`Validation failed: \${validation.errors.join(', ')}\`);
|
|
163
|
+
|
|
164
|
+
const ${modelVar} = await insertOne(TABLE_NAME, { ...data });
|
|
165
|
+
|
|
166
|
+
await eventBus.publish('${modelName}Created', { ...${modelVar}, timestamp: new Date().toISOString() } as any);
|
|
167
|
+
return ${modelVar};
|
|
168
|
+
}
|
|
169
|
+
`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function generateRetrieveMethod(modelName: string, _modelVar: string): string {
|
|
173
|
+
return `
|
|
174
|
+
/**
|
|
175
|
+
* Retrieve ${modelName} by id. Returns null when not found.
|
|
176
|
+
*/
|
|
177
|
+
public async retrieve(id: string): Promise<any | null> {
|
|
178
|
+
return await findOneByField(TABLE_NAME, 'id', id);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Retrieve a page of ${modelName}s.
|
|
183
|
+
*/
|
|
184
|
+
public async retrieveAll(options: { skip?: number; take?: number } = {}): Promise<any[]> {
|
|
185
|
+
if (!options.skip && !options.take) {
|
|
186
|
+
return await findAll(TABLE_NAME);
|
|
187
|
+
}
|
|
188
|
+
const limit = options.take ?? 100;
|
|
189
|
+
const offset = options.skip ?? 0;
|
|
190
|
+
const result = await query(\`SELECT * FROM "\${TABLE_NAME}" LIMIT $1 OFFSET $2\`, [limit, offset]);
|
|
191
|
+
return result.rows;
|
|
192
|
+
}
|
|
193
|
+
`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function generateUpdateMethod(modelName: string, modelVar: string): string {
|
|
197
|
+
return `
|
|
198
|
+
/**
|
|
199
|
+
* Update ${modelName}.
|
|
200
|
+
*/
|
|
201
|
+
public async update(id: string, data: any): Promise<any> {
|
|
202
|
+
const validation = this.validate(data, { operation: 'update' });
|
|
203
|
+
if (!validation.valid) throw new Error(\`Validation failed: \${validation.errors.join(', ')}\`);
|
|
204
|
+
|
|
205
|
+
// Strip nested objects + id — only scalar fields are written.
|
|
206
|
+
const updateData: Record<string, unknown> = {};
|
|
207
|
+
for (const [key, value] of Object.entries(data)) {
|
|
208
|
+
if (key === 'id') continue;
|
|
209
|
+
if (Array.isArray(value)) continue;
|
|
210
|
+
if (value !== null && typeof value === 'object' && !(value instanceof Date)) continue;
|
|
211
|
+
updateData[key] = value;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
await updateOneById(TABLE_NAME, id, updateData);
|
|
215
|
+
const ${modelVar} = await findOneByField(TABLE_NAME, 'id', id);
|
|
216
|
+
if (!${modelVar}) throw new Error('${modelName} not found after update');
|
|
217
|
+
|
|
218
|
+
await eventBus.publish('${modelName}Updated', { ...${modelVar}, timestamp: new Date().toISOString() } as any);
|
|
219
|
+
return ${modelVar};
|
|
220
|
+
}
|
|
221
|
+
`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function generateEvolveMethod(model: any, modelName: string, modelVar: string): string {
|
|
225
|
+
const lifecycles = Array.isArray(model.lifecycles)
|
|
226
|
+
? model.lifecycles
|
|
227
|
+
: (model.lifecycles ? Object.entries(model.lifecycles).map(([name, lc]: [string, any]) => ({ name, ...lc })) : []);
|
|
228
|
+
const lifecycle = lifecycles[0];
|
|
229
|
+
const lifecycleName = lifecycle?.name || 'status';
|
|
230
|
+
const validTransitions = lifecycle ? buildTransitionMap(lifecycle) : {};
|
|
231
|
+
const states = lifecycle?.states && lifecycle.states.length > 0
|
|
232
|
+
? lifecycle.states.map((s: any) => typeof s === 'string' ? s : s.name)
|
|
233
|
+
: Array.from(new Set([
|
|
234
|
+
...Object.keys(validTransitions),
|
|
235
|
+
...Object.values(validTransitions).flat(),
|
|
236
|
+
]));
|
|
237
|
+
|
|
238
|
+
return `
|
|
239
|
+
/**
|
|
240
|
+
* Evolve ${modelName} through lifecycle "${lifecycleName}"
|
|
241
|
+
* States: ${states.join(' → ') || '(none declared)'}
|
|
242
|
+
*/
|
|
243
|
+
public async evolve(id: string, data: any): Promise<any> {
|
|
244
|
+
const current = await findOneByField(TABLE_NAME, 'id', id);
|
|
245
|
+
if (!current) throw new Error('${modelName} not found');
|
|
246
|
+
|
|
247
|
+
const targetLifecycle = data?.lifecycleName || '${lifecycleName}';
|
|
248
|
+
const targetState = data?.toState ?? data?.state ?? data?.[targetLifecycle];
|
|
249
|
+
if (!targetState) throw new Error('evolve requires toState (or ${lifecycleName}) in the request body');
|
|
250
|
+
|
|
251
|
+
${states.length > 0 ? `
|
|
252
|
+
const currentState = (current as any)[targetLifecycle];
|
|
253
|
+
const validTransitions: Record<string, string[]> = ${JSON.stringify(validTransitions)};
|
|
254
|
+
const allowed = validTransitions[currentState] || [];
|
|
255
|
+
if (!allowed.includes(targetState)) {
|
|
256
|
+
throw new Error(\`Invalid transition: \${currentState} → \${targetState}. Allowed: \${allowed.join(', ') || 'none'}\`);
|
|
257
|
+
}
|
|
258
|
+
` : ''}
|
|
259
|
+
|
|
260
|
+
await updateOneById(TABLE_NAME, id, { [targetLifecycle]: targetState });
|
|
261
|
+
const ${modelVar} = await findOneByField(TABLE_NAME, 'id', id);
|
|
262
|
+
if (!${modelVar}) throw new Error('${modelName} not found after evolve');
|
|
263
|
+
|
|
264
|
+
await eventBus.publish('${modelName}Evolved', { ...${modelVar}, timestamp: new Date().toISOString() } as any);
|
|
265
|
+
return ${modelVar};
|
|
266
|
+
}
|
|
267
|
+
`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function generateDeleteMethod(modelName: string, modelVar: string): string {
|
|
271
|
+
return `
|
|
272
|
+
/**
|
|
273
|
+
* Delete ${modelName}.
|
|
274
|
+
*/
|
|
275
|
+
public async delete(id: string): Promise<void> {
|
|
276
|
+
const ${modelVar} = await findOneByField(TABLE_NAME, 'id', id);
|
|
277
|
+
await deleteOneById(TABLE_NAME, id);
|
|
278
|
+
if (${modelVar}) {
|
|
279
|
+
await eventBus.publish('${modelName}Deleted', { ...${modelVar}, timestamp: new Date().toISOString() } as any);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
interface CustomActionsResult {
|
|
286
|
+
code: string;
|
|
287
|
+
needsAiBehaviors: boolean;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
import { matchPgStep, type PgStepContext } from './step-conventions.js';
|
|
291
|
+
|
|
292
|
+
function generateCustomActions(controller: any, modelRegistry: Record<string, any> = {}): CustomActionsResult {
|
|
293
|
+
if (!controller.actions || Object.keys(controller.actions).length === 0) {
|
|
294
|
+
return { code: '', needsAiBehaviors: false };
|
|
295
|
+
}
|
|
296
|
+
const CRUD_NAMES = new Set(['create', 'retrieve', 'retrieveAll', 'update', 'evolve', 'delete', 'validate']);
|
|
297
|
+
const modelName = controller.model || (controller.name || '').replace(/Controller$/, '') || 'Model';
|
|
298
|
+
const tableNameLocal = modelName.toLowerCase() + 's';
|
|
299
|
+
const out: string[] = [];
|
|
300
|
+
let needsAiBehaviors = false;
|
|
301
|
+
for (const [actionName, action] of Object.entries<any>(controller.actions)) {
|
|
302
|
+
if (CRUD_NAMES.has(actionName)) {
|
|
303
|
+
console.warn(
|
|
304
|
+
`⚠️ ${controller.name || 'Controller'}.${actionName} — behaviour-derived action collides with the auto-generated CURVED \`${actionName}\` op. Dropped to avoid TS2393 duplicate-implementation. Rename the behaviour (e.g. \`${actionName}Soft\` / \`hardDelete\`) if you need the custom logic.`
|
|
305
|
+
);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
const steps: any[] = Array.isArray(action.steps) ? action.steps : [];
|
|
309
|
+
const stepsHeader = steps.length > 0
|
|
310
|
+
? steps.map((s: any) => ` * - ${typeof s === 'string' ? s : (s.action || JSON.stringify(s))}`).join('\n')
|
|
311
|
+
: ' * (no spec steps declared)';
|
|
312
|
+
|
|
313
|
+
const declaredVars = new Set<string>();
|
|
314
|
+
const stepBodies: string[] = [];
|
|
315
|
+
let usesArgs = false;
|
|
316
|
+
let actionRefersToAi = false;
|
|
317
|
+
steps.forEach((rawStep: any, i: number) => {
|
|
318
|
+
const stepText = typeof rawStep === 'string' ? rawStep : (rawStep?.step || rawStep?.action);
|
|
319
|
+
if (typeof stepText !== 'string') {
|
|
320
|
+
stepBodies.push(` // Step ${i + 1}: (non-string step ignored)`);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const ctx: PgStepContext = {
|
|
324
|
+
modelName,
|
|
325
|
+
tableName: tableNameLocal,
|
|
326
|
+
serviceName: controller.name || 'Controller',
|
|
327
|
+
operationName: actionName,
|
|
328
|
+
stepNum: i + 1,
|
|
329
|
+
parameterNames: Object.keys(action.parameters || {}),
|
|
330
|
+
declaredVars,
|
|
331
|
+
models: modelRegistry,
|
|
332
|
+
};
|
|
333
|
+
const result = matchPgStep(stepText, ctx);
|
|
334
|
+
stepBodies.push(result.call);
|
|
335
|
+
if (/\bargs\./.test(result.call)) usesArgs = true;
|
|
336
|
+
if (!result.matched) actionRefersToAi = true;
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
if (actionRefersToAi) needsAiBehaviors = true;
|
|
340
|
+
const argsParam = usesArgs ? 'args: any = {}' : '_args: any = {}';
|
|
341
|
+
let combined = stepBodies.join('\n\n');
|
|
342
|
+
// Drop unused step{N}Result locals (strict tsc's noUnusedLocals applies
|
|
343
|
+
// even to underscored names — same logic as the mongo controller).
|
|
344
|
+
const stepResultRe = /const\s+(step\d+Result)\s*=/g;
|
|
345
|
+
let mres: RegExpExecArray | null;
|
|
346
|
+
const declared: string[] = [];
|
|
347
|
+
while ((mres = stepResultRe.exec(combined)) !== null) declared.push(mres[1]!);
|
|
348
|
+
for (const name of declared) {
|
|
349
|
+
const refCount = (combined.match(new RegExp(`\\b${name}\\b`, 'g')) || []).length;
|
|
350
|
+
if (refCount <= 1) {
|
|
351
|
+
combined = combined.replace(new RegExp(`const\\s+${name}\\s*=\\s*`), '');
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
const body = steps.length > 0
|
|
355
|
+
? combined + `\n return { success: true };`
|
|
356
|
+
: ` throw new Error('${controller.name || 'Controller'}.${actionName} is not implemented');`;
|
|
357
|
+
|
|
358
|
+
out.push(`
|
|
359
|
+
/**
|
|
360
|
+
* ${actionName}
|
|
361
|
+
* ${action.description || ''}
|
|
362
|
+
*
|
|
363
|
+
* Spec steps:
|
|
364
|
+
${stepsHeader}
|
|
365
|
+
*/
|
|
366
|
+
public async ${actionName}(${argsParam}): Promise<any> {
|
|
367
|
+
${body}
|
|
368
|
+
}
|
|
369
|
+
`);
|
|
370
|
+
}
|
|
371
|
+
return { code: out.join('\n'), needsAiBehaviors };
|
|
372
|
+
}
|