@specverse/engines 6.16.0 → 6.18.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.
@@ -13,24 +13,35 @@
13
13
  * Float, Number → DOUBLE PRECISION
14
14
  * Boolean → BOOLEAN
15
15
  * DateTime, Date → TIMESTAMPTZ
16
- * JsonJSONB
16
+ * UUIDUUID
17
+ * Json, Jsonb → JSONB
17
18
  *
18
19
  * Primary key:
19
- * - `id UUID required` id UUID PRIMARY KEY DEFAULT gen_random_uuid()
20
- * - any other "id" required → id TEXT PRIMARY KEY
20
+ * - Composite via `model.keys: [a, b]` or `model.primaryKey: [a, b]`
21
+ * PRIMARY KEY (a, b) constraint at the end of the table; the columns
22
+ * are forced NOT NULL and the synthesised id column is suppressed.
23
+ * - `id UUID required` → id UUID PRIMARY KEY DEFAULT gen_random_uuid()
24
+ * - any other "id" required → id TEXT PRIMARY KEY
21
25
  *
22
- * Out of scope (deferred): partial indexes, RLS policies, check constraints
23
- * derived from `min/max` ranges, composite keys.
26
+ * Composite unique constraints:
27
+ * - `model.unique: [[a, b], [c, d]]` → UNIQUE (a, b) constraint per
28
+ * entry. Mixed: a single string array `[a, b]` is interpreted as ONE
29
+ * composite (NOT two single-column ones) — use the
30
+ * attribute-level `unique: true` for single-column uniqueness.
31
+ *
32
+ * Partial indexes:
33
+ * - `attribute.index: { where: "deleted_at IS NULL", unique: true }`
34
+ * emits `CREATE [UNIQUE] INDEX ... WHERE deleted_at IS NULL` after
35
+ * the table. Adopt for soft-delete + tenant-scoped uniqueness.
36
+ *
37
+ * Out of scope (deferred — see TODO #43L): RLS policies, check
38
+ * constraints derived from `min/max` ranges, materialised views.
24
39
  */
25
40
  import type { TemplateContext } from '@specverse/types';
26
41
 
27
42
  export default function generatePgSchemaSql(context: TemplateContext): string {
28
43
  const { spec, models } = context as any;
29
44
 
30
- // Pull a flat list of {modelName, model} from the most-likely shapes:
31
- // 1. Realize passes `models` (array of model specs from the resolved
32
- // component); use it directly.
33
- // 2. Otherwise walk spec.components[].models.
34
45
  const allModels: Array<[string, any]> = [];
35
46
  if (Array.isArray(models) && models.length > 0) {
36
47
  for (const m of models) if (m?.name) allModels.push([m.name, m]);
@@ -77,29 +88,57 @@ function generateTable(modelName: string, model: any): string {
77
88
  ? attrs.map((a: any) => [a.name, a])
78
89
  : Object.entries(attrs);
79
90
 
91
+ // Composite primary key — read either `keys` or `primaryKey`. Both
92
+ // accepted shapes: array of strings (one composite) OR a single string
93
+ // (degenerate single-column PK; used by some specs as a way to point
94
+ // at a non-`id` column).
95
+ const compositePk = parseColumnList(model.keys ?? model.primaryKey);
96
+ // Composite uniques — array of arrays, each defining one constraint.
97
+ const compositeUniques = parseCompositeUnique(model.unique);
98
+
80
99
  const columns: string[] = [];
81
100
  const indexes: string[] = [];
82
101
  let hasIdColumn = false;
102
+ const compositePkSet = new Set(compositePk);
83
103
 
84
104
  for (const [colName, attr] of list) {
85
105
  if (!colName) continue;
86
- const { sql, isPk, isUnique } = columnDef(colName, attr, modelName);
106
+ // When this column is part of a composite PK, force NOT NULL and
107
+ // skip the column-level PRIMARY KEY (the constraint goes at the end
108
+ // of the CREATE TABLE).
109
+ const partOfCompositePk = compositePkSet.has(colName);
110
+ const { sql, isPk, isUnique } = columnDef(colName, attr, modelName, partOfCompositePk);
87
111
  if (isPk) hasIdColumn = true;
88
112
  columns.push(' ' + sql);
89
- if (isUnique && !isPk) {
113
+
114
+ if (isUnique && !isPk && !partOfCompositePk) {
90
115
  indexes.push(
91
116
  `CREATE UNIQUE INDEX IF NOT EXISTS "${table}_${colName}_uq" ON "${table}" ("${colName}");`,
92
117
  );
93
- } else if (colName.endsWith('Id') && colName !== 'id') {
118
+ } else if (colName.endsWith('Id') && colName !== 'id' && !partOfCompositePk) {
94
119
  // FK columns — index for join performance.
95
120
  indexes.push(
96
121
  `CREATE INDEX IF NOT EXISTS "${table}_${colName}_idx" ON "${table}" ("${colName}");`,
97
122
  );
98
123
  }
124
+
125
+ // Partial indexes — declared at the attribute level so the column +
126
+ // its specialised index live together in the spec. `attr.index` is
127
+ // either `true` (plain index, no WHERE) or `{ where, unique }`.
128
+ const partial = parsePartialIndex(attr.index);
129
+ if (partial) {
130
+ const indexName = `${table}_${colName}${partial.unique ? '_uq' : '_idx'}_partial`;
131
+ const uniqueClause = partial.unique ? 'UNIQUE ' : '';
132
+ const whereClause = partial.where ? ` WHERE ${partial.where}` : '';
133
+ indexes.push(
134
+ `CREATE ${uniqueClause}INDEX IF NOT EXISTS "${indexName}" ON "${table}" ("${colName}")${whereClause};`,
135
+ );
136
+ }
99
137
  }
100
138
 
101
- // No id declared in spec — synthesise one so the table is keyed.
102
- if (!hasIdColumn) {
139
+ // Composite PK takes precedence over the synthesised id column. Skip
140
+ // the synthesised id only if the composite covers every "id" need.
141
+ if (compositePk.length === 0 && !hasIdColumn) {
103
142
  columns.unshift(' "id" TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text');
104
143
  }
105
144
 
@@ -112,11 +151,7 @@ function generateTable(modelName: string, model: any): string {
112
151
  columns.push(' "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW()');
113
152
  }
114
153
 
115
- // Lifecycle columns — for every declared lifecycle, add a TEXT column
116
- // (named after the lifecycle, defaulting to its first state) so
117
- // controller.evolve has somewhere to write the transitioned state.
118
- // Mongo is schemaless so this happens implicitly there; in postgres we
119
- // need an explicit column.
154
+ // Lifecycle columns.
120
155
  const lifecycles = Array.isArray(model.lifecycles)
121
156
  ? model.lifecycles
122
157
  : (model.lifecycles ? Object.entries(model.lifecycles).map(([name, lc]: [string, any]) => ({ name, ...lc })) : []);
@@ -128,6 +163,16 @@ function generateTable(modelName: string, model: any): string {
128
163
  columns.push(` "${lcName}" TEXT NOT NULL${defaultClause}`);
129
164
  }
130
165
 
166
+ // Table-level constraints (composite PK + composite uniques) go at
167
+ // the end of the column list so the table reads top-to-bottom in the
168
+ // natural order: columns, then constraints.
169
+ if (compositePk.length > 0) {
170
+ columns.push(` PRIMARY KEY (${compositePk.map(quoteIdent).join(', ')})`);
171
+ }
172
+ for (const cols of compositeUniques) {
173
+ columns.push(` UNIQUE (${cols.map(quoteIdent).join(', ')})`);
174
+ }
175
+
131
176
  const indexBlock = indexes.length > 0 ? '\n\n' + indexes.join('\n') : '';
132
177
  return `-- ${modelName}\nCREATE TABLE IF NOT EXISTS "${table}" (\n${columns.join(',\n')}\n);${indexBlock}`;
133
178
  }
@@ -138,13 +183,15 @@ interface ColumnDefResult {
138
183
  isUnique: boolean;
139
184
  }
140
185
 
141
- function columnDef(name: string, attr: any, _modelName: string): ColumnDefResult {
142
- const required = !!attr.required;
186
+ function columnDef(name: string, attr: any, _modelName: string, forceNotNull: boolean): ColumnDefResult {
187
+ const required = !!attr.required || forceNotNull;
143
188
  const unique = !!attr.unique;
144
189
  const type = (attr.type || 'String').toLowerCase();
145
190
  const isIdColumn = name === 'id';
146
191
 
147
- if (isIdColumn) {
192
+ // When the column is part of a composite PK, suppress the column-level
193
+ // PRIMARY KEY — the table-level PRIMARY KEY (a, b) constraint covers it.
194
+ if (isIdColumn && !forceNotNull) {
148
195
  if (type === 'uuid') {
149
196
  return { sql: `"id" UUID PRIMARY KEY DEFAULT gen_random_uuid()`, isPk: true, isUnique: true };
150
197
  }
@@ -212,7 +259,6 @@ function deriveInitialState(lc: any): string | null {
212
259
  return head || null;
213
260
  }
214
261
  if (lc.transitions && typeof lc.transitions === 'object') {
215
- // Pick the source side of the first transition.
216
262
  const first = Object.values(lc.transitions)[0];
217
263
  if (typeof first === 'string') return first.split(/\s*->\s*/)[0]?.trim() || null;
218
264
  }
@@ -229,8 +275,78 @@ function formatColumnDefault(value: any, type: string): string {
229
275
  if (value === 'now' && (type === 'datetime' || type === 'date' || type === 'timestamp')) {
230
276
  return ` DEFAULT NOW()`;
231
277
  }
232
- // Quoted SQL string literal — escape single quotes.
233
278
  return ` DEFAULT '${value.replace(/'/g, "''")}'`;
234
279
  }
235
280
  return '';
236
281
  }
282
+
283
+ /** Parse a `keys:` / `primaryKey:` declaration. Accepts:
284
+ * - undefined / empty → []
285
+ * - string → [string] (single-column PK named explicitly; degenerate)
286
+ * - string[] → as-is (composite PK)
287
+ * - string[][] → flatten the first entry (defensive — some specs nest)
288
+ */
289
+ function parseColumnList(raw: any): string[] {
290
+ if (raw === undefined || raw === null) return [];
291
+ if (typeof raw === 'string') return [raw];
292
+ if (!Array.isArray(raw)) return [];
293
+ if (raw.length === 0) return [];
294
+ if (typeof raw[0] === 'string') return raw.filter((x: any): x is string => typeof x === 'string');
295
+ if (Array.isArray(raw[0])) {
296
+ return (raw[0] as any[]).filter((x: any): x is string => typeof x === 'string');
297
+ }
298
+ return [];
299
+ }
300
+
301
+ /** Parse a composite-unique declaration. Accepts:
302
+ * - undefined → []
303
+ * - string[] → [[...]] (single composite spanning the listed columns)
304
+ * - string[][] → as-is (multiple composites)
305
+ *
306
+ * Single-column uniqueness is intentionally left at the attribute level
307
+ * (`attr.unique: true`) so the spec author signals intent at the right
308
+ * scope. Mixing single + composite at this entry would be ambiguous.
309
+ */
310
+ function parseCompositeUnique(raw: any): string[][] {
311
+ if (raw === undefined || raw === null) return [];
312
+ if (!Array.isArray(raw) || raw.length === 0) return [];
313
+ if (typeof raw[0] === 'string') {
314
+ return [raw.filter((x: any): x is string => typeof x === 'string')];
315
+ }
316
+ if (Array.isArray(raw[0])) {
317
+ return (raw as any[][])
318
+ .map((row) => row.filter((x: any): x is string => typeof x === 'string'))
319
+ .filter((row) => row.length > 0);
320
+ }
321
+ return [];
322
+ }
323
+
324
+ interface PartialIndex {
325
+ where: string | null;
326
+ unique: boolean;
327
+ }
328
+
329
+ /** Parse an attribute-level `index` declaration. Accepts:
330
+ * - undefined / false → null (no extra index)
331
+ * - true → { where: null, unique: false } (plain index, no WHERE — same
332
+ * as a regular index but explicit; useful for non-FK columns)
333
+ * - { where, unique } → as-is
334
+ */
335
+ function parsePartialIndex(raw: any): PartialIndex | null {
336
+ if (raw === undefined || raw === null || raw === false) return null;
337
+ if (raw === true) return { where: null, unique: false };
338
+ if (typeof raw === 'object') {
339
+ const where = typeof raw.where === 'string' && raw.where.trim() !== ''
340
+ ? raw.where.trim()
341
+ : null;
342
+ const unique = !!raw.unique;
343
+ // No `where` AND no `unique` → equivalent to `index: true`. Still
344
+ // emit so the spec author's explicit declaration stays meaningful.
345
+ return { where, unique };
346
+ }
347
+ return null;
348
+ }
349
+
350
+ function quoteIdent(name: string): string {
351
+ return '"' + name.replace(/"/g, '""') + '"';
352
+ }
@@ -19,6 +19,7 @@
19
19
 
20
20
  import type { TemplateContext } from '@specverse/types';
21
21
  import { matchStep, type StepContext } from './step-conventions.js';
22
+ import { validateImportWhitelist } from '@specverse/engines/ai';
22
23
  import { createHash } from 'crypto';
23
24
  import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'fs';
24
25
  import { dirname, join } from 'path';
@@ -59,6 +60,26 @@ async function validateTypeScript(code: string): Promise<string | null> {
59
60
  * unused locals, undefined references. Reprompting with the tsc error
60
61
  * lets the LLM self-correct without burning a per-step retry.
61
62
  */
63
+ /**
64
+ * Validate that every dynamic `await import('LIT')` in `code` references
65
+ * a whitelisted library. Returns null if clean; an error message
66
+ * otherwise. The whitelist (and the rationale behind each entry) lives
67
+ * in `engines/src/ai/library-whitelist.ts` — single source of truth.
68
+ *
69
+ * This closes the gap noted in TODO #43K-B-review: pre-fix, the type
70
+ * validator above failed on every dynamic import (whitelisted or not)
71
+ * because the engines workspace has none of the whitelist libs
72
+ * installed; bodies were silently routed through the AI-INVALID
73
+ * fallback regardless of import legality. Bodies importing
74
+ * non-whitelisted libs would crash at runtime in the realized backend.
75
+ * Now we reject them at generation time.
76
+ */
77
+ function validateImports(code: string): string | null {
78
+ const offenders = validateImportWhitelist(code);
79
+ if (offenders.length === 0) return null;
80
+ return `import not in whitelist: ${offenders.join(', ')} (allowed: jsonwebtoken | bcryptjs | uuid | crypto | expr-eval)`;
81
+ }
82
+
62
83
  async function validateTypeScriptTypes(code: string): Promise<string | null> {
63
84
  let ts: any;
64
85
  try {
@@ -135,7 +156,7 @@ async function validateTypeScriptTypes(code: string): Promise<string | null> {
135
156
  * produces a new hash. The prompt version is part of the hash so
136
157
  * prompt upgrades also invalidate.
137
158
  */
138
- const PROMPT_VERSION = '9.7.0';
159
+ const PROMPT_VERSION = '9.8.0'; // 9.8: import-whitelist enforcement (#43K-B-review)
139
160
 
140
161
  function cacheKey(step: string, modelName: string, operationName: string, functionName: string, inputs: string[]): string {
141
162
  const payload = JSON.stringify({ step, modelName, operationName, functionName, inputs: [...inputs].sort(), v: PROMPT_VERSION });
@@ -458,8 +479,9 @@ export async function generateAiBehaviorsFile(opts: {
458
479
  const testCode = `export async function ${functionName}(input: any): Promise<any> {\n${body}\n}`;
459
480
  const syntaxError = await validateTypeScript(testCode);
460
481
  const typeError = syntaxError ? null : await validateTypeScriptTypes(testCode);
461
- if (syntaxError || typeError) {
462
- console.warn(` [ai-validate] cached ${functionName} failed validation: ${syntaxError || typeError}`);
482
+ const importError = (syntaxError || typeError) ? null : validateImports(testCode);
483
+ if (syntaxError || typeError || importError) {
484
+ console.warn(` [ai-validate] cached ${functionName} failed validation: ${syntaxError || typeError || importError}`);
463
485
  body = null; // Force regeneration
464
486
  source = 'STUB';
465
487
  } else {
@@ -498,10 +520,18 @@ export async function generateAiBehaviorsFile(opts: {
498
520
  source = 'AI-INVALID';
499
521
  } else {
500
522
  const typeError = await validateTypeScriptTypes(testCode);
501
- if (typeError) {
502
- console.warn(` [ai-validate] ${functionName} type errors: ${typeError}`);
523
+ // Whitelist gate runs after type check so error precedence is
524
+ // syntax > types > imports (most-actionable error first).
525
+ const importError = typeError ? null : validateImports(testCode);
526
+ if (typeError || importError) {
527
+ console.warn(` [ai-validate] ${functionName} ${typeError ? 'type errors: ' + typeError : 'whitelist violation: ' + importError}`);
503
528
  try {
504
- const retryHint = `Your previous output produced TypeScript type errors:\n${typeError}\n\nFix these specifically — common causes:\n- RegExp match indices are 'string | undefined'; use non-null assertion or extract to a typed variable\n- Strict null checks: guard or assert before use\n- Don't declare locals you never reference\n\nIMPORTANT: The destructure line \`const { ... } = input;\` is added by the wrapper, NOT by you. Output ONLY the function body that goes AFTER that line — do not repeat the destructure or you will produce duplicate-declaration errors.`;
529
+ // Build a retry hint that targets whichever gate failed.
530
+ // Mixed failures (type AND import) get both messages.
531
+ const errorParts: string[] = [];
532
+ if (typeError) errorParts.push(`TypeScript type errors:\n${typeError}`);
533
+ if (importError) errorParts.push(`Import-whitelist violation: ${importError}.\nOnly these libraries may be dynamic-imported: jsonwebtoken, bcryptjs, uuid, crypto, expr-eval. Anything else is forbidden — throw an Error if the step needs an unsupported library.`);
534
+ const retryHint = `Your previous output had problems:\n\n${errorParts.join('\n\n')}\n\nFix these specifically — common type-error causes:\n- RegExp match indices are 'string | undefined'; use non-null assertion or extract to a typed variable\n- Strict null checks: guard or assert before use\n- Don't declare locals you never reference\n\nIMPORTANT: The destructure line \`const { ... } = input;\` is added by the wrapper, NOT by you. Output ONLY the function body that goes AFTER that line — do not repeat the destructure or you will produce duplicate-declaration errors.`;
505
535
  const retried = await aiService.generateBehavior({
506
536
  step: `${step}\n\n${retryHint}`,
507
537
  modelName,
@@ -516,7 +546,8 @@ export async function generateAiBehaviorsFile(opts: {
516
546
  const retryCode = `export async function ${functionName}(input: any): Promise<any> {\n${retried}\n}`;
517
547
  const retrySyntaxError = await validateTypeScript(retryCode);
518
548
  const retryTypeError = retrySyntaxError ? null : await validateTypeScriptTypes(retryCode);
519
- if (!retrySyntaxError && !retryTypeError) {
549
+ const retryImportError = (retrySyntaxError || retryTypeError) ? null : validateImports(retryCode);
550
+ if (!retrySyntaxError && !retryTypeError && !retryImportError) {
520
551
  body = retried;
521
552
  source = 'AI-GENERATED';
522
553
  cacheWrite(key, body);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specverse/engines",
3
- "version": "6.16.0",
3
+ "version": "6.18.0",
4
4
  "description": "SpecVerse toolchain — parser, inference, realize, generators, AI, registry, bundles",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",