@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.
Files changed (47) 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/inference/core/specly-converter.d.ts.map +1 -1
  4. package/dist/inference/core/specly-converter.js +20 -0
  5. package/dist/inference/core/specly-converter.js.map +1 -1
  6. package/dist/inference/index.d.ts.map +1 -1
  7. package/dist/inference/index.js +72 -22
  8. package/dist/inference/index.js.map +1 -1
  9. package/dist/inference/logical/generators/controller-generator.d.ts.map +1 -1
  10. package/dist/inference/logical/generators/controller-generator.js +26 -4
  11. package/dist/inference/logical/generators/controller-generator.js.map +1 -1
  12. package/dist/libs/instance-factories/applications/templates/generic/backend-package-json-generator.js +22 -5
  13. package/dist/libs/instance-factories/controllers/templates/fastify/routes-generator.js +50 -15
  14. package/dist/libs/instance-factories/controllers/templates/fastify/server-generator.js +26 -6
  15. package/dist/libs/instance-factories/services/postgres-native-services.yaml +90 -0
  16. package/dist/libs/instance-factories/services/templates/_shared/step-matching.js +44 -0
  17. package/dist/libs/instance-factories/services/templates/mongodb-native/controller-generator.js +68 -13
  18. package/dist/libs/instance-factories/services/templates/mongodb-native/step-conventions.js +515 -0
  19. package/dist/libs/instance-factories/services/templates/postgres-native/client-generator.js +165 -0
  20. package/dist/libs/instance-factories/services/templates/postgres-native/controller-generator.js +300 -0
  21. package/dist/libs/instance-factories/services/templates/postgres-native/ddl-generator.js +169 -0
  22. package/dist/libs/instance-factories/services/templates/postgres-native/service-generator.js +65 -0
  23. package/dist/libs/instance-factories/services/templates/postgres-native/step-conventions.js +433 -0
  24. package/dist/libs/instance-factories/services/templates/prisma/ai-behaviors-generator.js +27 -4
  25. package/dist/libs/instance-factories/services/templates/prisma/step-conventions.js +7 -34
  26. package/dist/parser/processors/ExecutableProcessor.d.ts.map +1 -1
  27. package/dist/parser/processors/ExecutableProcessor.js +14 -1
  28. package/dist/parser/processors/ExecutableProcessor.js.map +1 -1
  29. package/dist/realize/index.d.ts.map +1 -1
  30. package/dist/realize/index.js +30 -3
  31. package/dist/realize/index.js.map +1 -1
  32. package/libs/instance-factories/applications/templates/generic/backend-package-json-generator.ts +46 -24
  33. package/libs/instance-factories/controllers/templates/fastify/routes-generator.ts +80 -21
  34. package/libs/instance-factories/controllers/templates/fastify/server-generator.ts +48 -7
  35. package/libs/instance-factories/services/postgres-native-services.yaml +90 -0
  36. package/libs/instance-factories/services/templates/_shared/step-matching.ts +103 -0
  37. package/libs/instance-factories/services/templates/mongodb-native/controller-generator.ts +97 -23
  38. package/libs/instance-factories/services/templates/mongodb-native/step-conventions.ts +691 -0
  39. package/libs/instance-factories/services/templates/postgres-native/__tests__/controller-generator.test.ts +193 -0
  40. package/libs/instance-factories/services/templates/postgres-native/client-generator.ts +178 -0
  41. package/libs/instance-factories/services/templates/postgres-native/controller-generator.ts +372 -0
  42. package/libs/instance-factories/services/templates/postgres-native/ddl-generator.ts +236 -0
  43. package/libs/instance-factories/services/templates/postgres-native/service-generator.ts +84 -0
  44. package/libs/instance-factories/services/templates/postgres-native/step-conventions.ts +539 -0
  45. package/libs/instance-factories/services/templates/prisma/ai-behaviors-generator.ts +61 -7
  46. package/libs/instance-factories/services/templates/prisma/step-conventions.ts +21 -68
  47. package/package.json +4 -3
@@ -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
+ });
@@ -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
+ }