@specverse/engines 6.17.0 → 6.18.1

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.
@@ -38,25 +38,38 @@ CREATE TABLE IF NOT EXISTS "${table}" (
38
38
  );`;
39
39
  }
40
40
  const list = Array.isArray(attrs) ? attrs.map((a) => [a.name, a]) : Object.entries(attrs);
41
+ const compositePk = parseColumnList(model.keys ?? model.primaryKey);
42
+ const compositeUniques = parseCompositeUnique(model.unique);
41
43
  const columns = [];
42
44
  const indexes = [];
43
45
  let hasIdColumn = false;
46
+ const compositePkSet = new Set(compositePk);
44
47
  for (const [colName, attr] of list) {
45
48
  if (!colName) continue;
46
- const { sql, isPk, isUnique } = columnDef(colName, attr, modelName);
49
+ const partOfCompositePk = compositePkSet.has(colName);
50
+ const { sql, isPk, isUnique } = columnDef(colName, attr, modelName, partOfCompositePk);
47
51
  if (isPk) hasIdColumn = true;
48
52
  columns.push(" " + sql);
49
- if (isUnique && !isPk) {
53
+ if (isUnique && !isPk && !partOfCompositePk) {
50
54
  indexes.push(
51
55
  `CREATE UNIQUE INDEX IF NOT EXISTS "${table}_${colName}_uq" ON "${table}" ("${colName}");`
52
56
  );
53
- } else if (colName.endsWith("Id") && colName !== "id") {
57
+ } else if (colName.endsWith("Id") && colName !== "id" && !partOfCompositePk) {
54
58
  indexes.push(
55
59
  `CREATE INDEX IF NOT EXISTS "${table}_${colName}_idx" ON "${table}" ("${colName}");`
56
60
  );
57
61
  }
62
+ const partial = parsePartialIndex(attr.index);
63
+ if (partial) {
64
+ const indexName = `${table}_${colName}${partial.unique ? "_uq" : "_idx"}_partial`;
65
+ const uniqueClause = partial.unique ? "UNIQUE " : "";
66
+ const whereClause = partial.where ? ` WHERE ${partial.where}` : "";
67
+ indexes.push(
68
+ `CREATE ${uniqueClause}INDEX IF NOT EXISTS "${indexName}" ON "${table}" ("${colName}")${whereClause};`
69
+ );
70
+ }
58
71
  }
59
- if (!hasIdColumn) {
72
+ if (compositePk.length === 0 && !hasIdColumn) {
60
73
  columns.unshift(' "id" TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text');
61
74
  }
62
75
  const declaredNames = new Set(list.map(([n]) => n));
@@ -74,18 +87,24 @@ CREATE TABLE IF NOT EXISTS "${table}" (
74
87
  const defaultClause = initial ? ` DEFAULT '${initial.replace(/'/g, "''")}'` : "";
75
88
  columns.push(` "${lcName}" TEXT NOT NULL${defaultClause}`);
76
89
  }
90
+ if (compositePk.length > 0) {
91
+ columns.push(` PRIMARY KEY (${compositePk.map(quoteIdent).join(", ")})`);
92
+ }
93
+ for (const cols of compositeUniques) {
94
+ columns.push(` UNIQUE (${cols.map(quoteIdent).join(", ")})`);
95
+ }
77
96
  const indexBlock = indexes.length > 0 ? "\n\n" + indexes.join("\n") : "";
78
97
  return `-- ${modelName}
79
98
  CREATE TABLE IF NOT EXISTS "${table}" (
80
99
  ${columns.join(",\n")}
81
100
  );${indexBlock}`;
82
101
  }
83
- function columnDef(name, attr, _modelName) {
84
- const required = !!attr.required;
102
+ function columnDef(name, attr, _modelName, forceNotNull) {
103
+ const required = !!attr.required || forceNotNull;
85
104
  const unique = !!attr.unique;
86
105
  const type = (attr.type || "String").toLowerCase();
87
106
  const isIdColumn = name === "id";
88
- if (isIdColumn) {
107
+ if (isIdColumn && !forceNotNull) {
89
108
  if (type === "uuid") {
90
109
  return { sql: `"id" UUID PRIMARY KEY DEFAULT gen_random_uuid()`, isPk: true, isUnique: true };
91
110
  }
@@ -164,6 +183,41 @@ function formatColumnDefault(value, type) {
164
183
  }
165
184
  return "";
166
185
  }
186
+ function parseColumnList(raw) {
187
+ if (raw === void 0 || raw === null) return [];
188
+ if (typeof raw === "string") return [raw];
189
+ if (!Array.isArray(raw)) return [];
190
+ if (raw.length === 0) return [];
191
+ if (typeof raw[0] === "string") return raw.filter((x) => typeof x === "string");
192
+ if (Array.isArray(raw[0])) {
193
+ return raw[0].filter((x) => typeof x === "string");
194
+ }
195
+ return [];
196
+ }
197
+ function parseCompositeUnique(raw) {
198
+ if (raw === void 0 || raw === null) return [];
199
+ if (!Array.isArray(raw) || raw.length === 0) return [];
200
+ if (typeof raw[0] === "string") {
201
+ return [raw.filter((x) => typeof x === "string")];
202
+ }
203
+ if (Array.isArray(raw[0])) {
204
+ return raw.map((row) => row.filter((x) => typeof x === "string")).filter((row) => row.length > 0);
205
+ }
206
+ return [];
207
+ }
208
+ function parsePartialIndex(raw) {
209
+ if (raw === void 0 || raw === null || raw === false) return null;
210
+ if (raw === true) return { where: null, unique: false };
211
+ if (typeof raw === "object") {
212
+ const where = typeof raw.where === "string" && raw.where.trim() !== "" ? raw.where.trim() : null;
213
+ const unique = !!raw.unique;
214
+ return { where, unique };
215
+ }
216
+ return null;
217
+ }
218
+ function quoteIdent(name) {
219
+ return '"' + name.replace(/"/g, '""') + '"';
220
+ }
167
221
  export {
168
222
  generatePgSchemaSql as default
169
223
  };
@@ -81,7 +81,7 @@ async function validateTypeScriptTypes(code) {
81
81
  return null;
82
82
  }
83
83
  }
84
- const PROMPT_VERSION = "9.8.0";
84
+ const PROMPT_VERSION = "9.8.1";
85
85
  function cacheKey(step, modelName, operationName, functionName, inputs) {
86
86
  const payload = JSON.stringify({ step, modelName, operationName, functionName, inputs: [...inputs].sort(), v: PROMPT_VERSION });
87
87
  return createHash("sha256").update(payload).digest("hex").slice(0, 16);
@@ -223,7 +223,7 @@ async function generateAiBehaviorsFile(opts) {
223
223
  };
224
224
  const isRealReference = (n, codeOnly) => {
225
225
  const escaped = n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
226
- const re = new RegExp(`(?<![A-Za-z0-9_$])${escaped}(?![A-Za-z0-9_$:])`, "g");
226
+ const re = new RegExp(`(?<![A-Za-z0-9_$.])${escaped}(?![A-Za-z0-9_$:])`, "g");
227
227
  return re.test(codeOnly);
228
228
  };
229
229
  const buildSignatureAndDestructure = (body2) => {
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Regression tests for the postgres-native DDL generator.
3
+ *
4
+ * Pins existing functionality (CREATE TABLE shape, type mapping, FK
5
+ * indexes, lifecycle columns, default values, unique constraints) AND
6
+ * the new Tier 1 / Tier 2 features added in engines 6.18.0:
7
+ * - Composite primary keys (`keys: [a, b]`, `primaryKey: [a, b]`)
8
+ * - Composite unique constraints (`unique: [[a, b]]`)
9
+ * - Partial indexes (`attr.index: { where, unique }`)
10
+ * - JSON type mapping (already present; pinned as a regression)
11
+ *
12
+ * Tests assert SQL substrings rather than full-text equality so a future
13
+ * comment / formatting tweak doesn't cascade into 30 broken tests.
14
+ */
15
+ import { describe, it, expect } from 'vitest';
16
+ import generatePgSchemaSql from '../ddl-generator.js';
17
+
18
+ const todoModel = {
19
+ name: 'Todo',
20
+ attributes: {
21
+ id: { name: 'id', type: 'UUID', required: true, auto: 'uuid4' },
22
+ title: { name: 'title', type: 'String', required: true },
23
+ completed: { name: 'completed', type: 'Boolean', default: false },
24
+ },
25
+ };
26
+
27
+ describe('postgres-native — ddl-generator (existing functionality)', () => {
28
+ it('emits CREATE TABLE IF NOT EXISTS + pgcrypto extension', () => {
29
+ const sql = generatePgSchemaSql({ models: [todoModel] } as any);
30
+ expect(sql).toContain('CREATE EXTENSION IF NOT EXISTS "pgcrypto"');
31
+ expect(sql).toContain('CREATE TABLE IF NOT EXISTS "todos"');
32
+ });
33
+
34
+ it('maps types correctly (TEXT/INTEGER/BOOLEAN/TIMESTAMPTZ/UUID)', () => {
35
+ const model = {
36
+ name: 'Mix',
37
+ attributes: {
38
+ id: { name: 'id', type: 'UUID', required: true },
39
+ title: { name: 'title', type: 'String', required: true },
40
+ count: { name: 'count', type: 'Integer', required: true },
41
+ ratio: { name: 'ratio', type: 'Float' },
42
+ active: { name: 'active', type: 'Boolean', default: true },
43
+ when: { name: 'when', type: 'DateTime' },
44
+ },
45
+ };
46
+ const sql = generatePgSchemaSql({ models: [model] } as any);
47
+ expect(sql).toContain('"title" TEXT NOT NULL');
48
+ expect(sql).toContain('"count" INTEGER NOT NULL');
49
+ expect(sql).toContain('"ratio" DOUBLE PRECISION');
50
+ expect(sql).toContain('"active" BOOLEAN');
51
+ expect(sql).toContain('"when" TIMESTAMPTZ');
52
+ expect(sql).toContain('"id" UUID PRIMARY KEY DEFAULT gen_random_uuid()');
53
+ });
54
+
55
+ it('synthesises an id column when no id is declared', () => {
56
+ const model = {
57
+ name: 'NoId',
58
+ attributes: { name: { name: 'name', type: 'String', required: true } },
59
+ };
60
+ const sql = generatePgSchemaSql({ models: [model] } as any);
61
+ expect(sql).toContain('"id" TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text');
62
+ });
63
+
64
+ it('emits FK indexes for *Id columns', () => {
65
+ const model = {
66
+ name: 'Item',
67
+ attributes: {
68
+ id: { name: 'id', type: 'UUID', required: true },
69
+ userId: { name: 'userId', type: 'UUID', required: true },
70
+ categoryId: { name: 'categoryId', type: 'UUID', required: true },
71
+ },
72
+ };
73
+ const sql = generatePgSchemaSql({ models: [model] } as any);
74
+ expect(sql).toContain('CREATE INDEX IF NOT EXISTS "items_userId_idx" ON "items" ("userId")');
75
+ expect(sql).toContain('CREATE INDEX IF NOT EXISTS "items_categoryId_idx" ON "items" ("categoryId")');
76
+ });
77
+
78
+ it('emits unique indexes for column-level `unique: true`', () => {
79
+ const model = {
80
+ name: 'User',
81
+ attributes: {
82
+ id: { name: 'id', type: 'UUID', required: true },
83
+ email: { name: 'email', type: 'String', required: true, unique: true },
84
+ },
85
+ };
86
+ const sql = generatePgSchemaSql({ models: [model] } as any);
87
+ expect(sql).toContain('CREATE UNIQUE INDEX IF NOT EXISTS "users_email_uq" ON "users" ("email")');
88
+ });
89
+
90
+ it('honours `model.storage.table` override', () => {
91
+ const sql = generatePgSchemaSql({
92
+ models: [{ ...todoModel, storage: { table: 'custom_todos' } }],
93
+ } as any);
94
+ expect(sql).toContain('CREATE TABLE IF NOT EXISTS "custom_todos"');
95
+ expect(sql).not.toContain('CREATE TABLE IF NOT EXISTS "todos"');
96
+ });
97
+
98
+ it('emits lifecycle columns auto-derived from `flow:` shorthand', () => {
99
+ const model = {
100
+ ...todoModel,
101
+ lifecycles: { status: { name: 'status', flow: 'open -> closed' } },
102
+ };
103
+ const sql = generatePgSchemaSql({ models: [model] } as any);
104
+ expect(sql).toContain(`"status" TEXT NOT NULL DEFAULT 'open'`);
105
+ });
106
+
107
+ it('adds createdAt/updatedAt defaults if not declared', () => {
108
+ const sql = generatePgSchemaSql({ models: [todoModel] } as any);
109
+ expect(sql).toContain('"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW()');
110
+ expect(sql).toContain('"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW()');
111
+ });
112
+
113
+ it('returns a stub when no models declared', () => {
114
+ expect(generatePgSchemaSql({} as any)).toContain('No models declared');
115
+ expect(generatePgSchemaSql({ models: [] } as any)).toContain('No models declared');
116
+ });
117
+ });
118
+
119
+ describe('postgres-native — ddl-generator (engines 6.18.0 — Tier 1: composite keys + uniques)', () => {
120
+ it('composite primary key via `keys: [a, b]` emits a table-level PRIMARY KEY constraint', () => {
121
+ const model = {
122
+ name: 'Membership',
123
+ attributes: {
124
+ tenantId: { name: 'tenantId', type: 'UUID', required: true },
125
+ userId: { name: 'userId', type: 'UUID', required: true },
126
+ role: { name: 'role', type: 'String', required: true },
127
+ },
128
+ keys: ['tenantId', 'userId'],
129
+ };
130
+ const sql = generatePgSchemaSql({ models: [model] } as any);
131
+ // Table-level constraint
132
+ expect(sql).toContain('PRIMARY KEY ("tenantId", "userId")');
133
+ // Both columns NOT NULL (forced by composite-PK membership)
134
+ expect(sql).toContain('"tenantId" UUID NOT NULL');
135
+ expect(sql).toContain('"userId" UUID NOT NULL');
136
+ // No synthesised id column (composite PK takes its place)
137
+ expect(sql).not.toContain('"id" TEXT PRIMARY KEY');
138
+ });
139
+
140
+ it('accepts `primaryKey:` as an alias for `keys:`', () => {
141
+ const model = {
142
+ name: 'Pair',
143
+ attributes: {
144
+ a: { name: 'a', type: 'String', required: true },
145
+ b: { name: 'b', type: 'String', required: true },
146
+ },
147
+ primaryKey: ['a', 'b'],
148
+ };
149
+ const sql = generatePgSchemaSql({ models: [model] } as any);
150
+ expect(sql).toContain('PRIMARY KEY ("a", "b")');
151
+ });
152
+
153
+ it('composite unique constraint via `unique: [[a, b]]`', () => {
154
+ const model = {
155
+ name: 'Slug',
156
+ attributes: {
157
+ id: { name: 'id', type: 'UUID', required: true },
158
+ tenantId: { name: 'tenantId', type: 'UUID', required: true },
159
+ slug: { name: 'slug', type: 'String', required: true },
160
+ },
161
+ unique: [['tenantId', 'slug']],
162
+ };
163
+ const sql = generatePgSchemaSql({ models: [model] } as any);
164
+ expect(sql).toContain('UNIQUE ("tenantId", "slug")');
165
+ });
166
+
167
+ it('multiple composite uniques', () => {
168
+ const model = {
169
+ name: 'Multi',
170
+ attributes: {
171
+ id: { name: 'id', type: 'UUID', required: true },
172
+ a: { name: 'a', type: 'String' },
173
+ b: { name: 'b', type: 'String' },
174
+ c: { name: 'c', type: 'String' },
175
+ },
176
+ unique: [['a', 'b'], ['b', 'c']],
177
+ };
178
+ const sql = generatePgSchemaSql({ models: [model] } as any);
179
+ expect(sql).toContain('UNIQUE ("a", "b")');
180
+ expect(sql).toContain('UNIQUE ("b", "c")');
181
+ });
182
+
183
+ it('a single-array `unique: [a, b]` is treated as ONE composite (not two single-column)', () => {
184
+ // This is the documented contract: single uniques go at the attribute
185
+ // level, not in `model.unique`. So `unique: ["a", "b"]` is one
186
+ // composite spanning a and b.
187
+ const model = {
188
+ name: 'Single',
189
+ attributes: {
190
+ id: { name: 'id', type: 'UUID', required: true },
191
+ a: { name: 'a', type: 'String' },
192
+ b: { name: 'b', type: 'String' },
193
+ },
194
+ unique: ['a', 'b'],
195
+ };
196
+ const sql = generatePgSchemaSql({ models: [model] } as any);
197
+ expect(sql).toContain('UNIQUE ("a", "b")');
198
+ // Did NOT generate two separate single-column unique indexes.
199
+ expect(sql).not.toContain('"single_a_uq"');
200
+ expect(sql).not.toContain('"single_b_uq"');
201
+ });
202
+
203
+ it('does not double-emit FK indexes for columns that are part of composite PK', () => {
204
+ // userId is the FK that would normally get an `_idx` index. When it's
205
+ // part of the PK, the PK already covers index lookups starting on
206
+ // userId, so the extra index would be wasted disk.
207
+ const model = {
208
+ name: 'Member',
209
+ attributes: {
210
+ userId: { name: 'userId', type: 'UUID', required: true },
211
+ groupId: { name: 'groupId', type: 'UUID', required: true },
212
+ },
213
+ keys: ['userId', 'groupId'],
214
+ };
215
+ const sql = generatePgSchemaSql({ models: [model] } as any);
216
+ expect(sql).not.toContain('"members_userId_idx"');
217
+ expect(sql).not.toContain('"members_groupId_idx"');
218
+ });
219
+ });
220
+
221
+ describe('postgres-native — ddl-generator (engines 6.18.0 — Tier 2: partial indexes + JSON)', () => {
222
+ it('partial unique index via `attr.index: { where, unique: true }`', () => {
223
+ const model = {
224
+ name: 'User',
225
+ attributes: {
226
+ id: { name: 'id', type: 'UUID', required: true },
227
+ email: {
228
+ name: 'email',
229
+ type: 'String',
230
+ required: true,
231
+ // Soft-delete-aware unique: only enforce uniqueness for live rows.
232
+ index: { where: '"deletedAt" IS NULL', unique: true },
233
+ },
234
+ deletedAt: { name: 'deletedAt', type: 'DateTime' },
235
+ },
236
+ };
237
+ const sql = generatePgSchemaSql({ models: [model] } as any);
238
+ expect(sql).toContain('CREATE UNIQUE INDEX IF NOT EXISTS "users_email_uq_partial" ON "users" ("email") WHERE "deletedAt" IS NULL');
239
+ });
240
+
241
+ it('plain partial index via `attr.index: { where: "..." }`', () => {
242
+ const model = {
243
+ name: 'Job',
244
+ attributes: {
245
+ id: { name: 'id', type: 'UUID', required: true },
246
+ status: {
247
+ name: 'status',
248
+ type: 'String',
249
+ index: { where: "status IN ('pending', 'running')" },
250
+ },
251
+ },
252
+ };
253
+ const sql = generatePgSchemaSql({ models: [model] } as any);
254
+ expect(sql).toContain(`CREATE INDEX IF NOT EXISTS "jobs_status_idx_partial" ON "jobs" ("status") WHERE status IN ('pending', 'running')`);
255
+ });
256
+
257
+ it('`attr.index: true` emits a plain index (no WHERE)', () => {
258
+ const model = {
259
+ name: 'Q',
260
+ attributes: {
261
+ id: { name: 'id', type: 'UUID', required: true },
262
+ priority: { name: 'priority', type: 'Integer', index: true },
263
+ },
264
+ };
265
+ const sql = generatePgSchemaSql({ models: [model] } as any);
266
+ // Index emitted without WHERE clause.
267
+ const idxLine = sql.match(/CREATE.*"qs_priority_idx_partial".*$/m)?.[0] ?? '';
268
+ expect(idxLine).toContain('"priority"');
269
+ expect(idxLine).not.toContain('WHERE');
270
+ });
271
+
272
+ it('JSON / Jsonb maps to JSONB column', () => {
273
+ const model = {
274
+ name: 'Doc',
275
+ attributes: {
276
+ id: { name: 'id', type: 'UUID', required: true },
277
+ payload: { name: 'payload', type: 'Json', required: true },
278
+ metadata: { name: 'metadata', type: 'Jsonb' },
279
+ },
280
+ };
281
+ const sql = generatePgSchemaSql({ models: [model] } as any);
282
+ expect(sql).toContain('"payload" JSONB NOT NULL');
283
+ expect(sql).toContain('"metadata" JSONB');
284
+ });
285
+ });
@@ -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
+ }
@@ -156,7 +156,7 @@ async function validateTypeScriptTypes(code: string): Promise<string | null> {
156
156
  * produces a new hash. The prompt version is part of the hash so
157
157
  * prompt upgrades also invalidate.
158
158
  */
159
- const PROMPT_VERSION = '9.8.0'; // 9.8: import-whitelist enforcement (#43K-B-review)
159
+ const PROMPT_VERSION = '9.8.1'; // 9.8.1: isRealReference excludes member access (.X) — fixes TS6198 on alias-via-cast bodies
160
160
 
161
161
  function cacheKey(step: string, modelName: string, operationName: string, functionName: string, inputs: string[]): string {
162
162
  const payload = JSON.stringify({ step, modelName, operationName, functionName, inputs: [...inputs].sort(), v: PROMPT_VERSION });
@@ -425,12 +425,16 @@ export async function generateAiBehaviorsFile(opts: {
425
425
 
426
426
  /** Does the body actually USE the local variable `n` (vs only mention
427
427
  * it as an object-literal property name like `step1Result:` in a
428
- * return object)? A real usage is `n` followed by something other than
429
- * `:` i.e. accessed in an expression. We approximate by requiring
430
- * the next non-whitespace char to be a code-meaningful operator/end. */
428
+ * return object, OR as a member access like `inp.token`)? A real
429
+ * usage is `n` not preceded by `.` (which would make it a property
430
+ * access on some other object) and not followed by `:` (object-literal
431
+ * key). The `.` in the lookbehind exclusion catches the case where
432
+ * the LLM aliases input via `const inp = input as any;` and then
433
+ * accesses `inp.token` etc. — the destructured names would otherwise
434
+ * count as referenced and we'd emit an unused-locals destructure. */
431
435
  const isRealReference = (n: string, codeOnly: string): boolean => {
432
436
  const escaped = n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
433
- const re = new RegExp(`(?<![A-Za-z0-9_$])${escaped}(?![A-Za-z0-9_$:])`, 'g');
437
+ const re = new RegExp(`(?<![A-Za-z0-9_$.])${escaped}(?![A-Za-z0-9_$:])`, 'g');
434
438
  return re.test(codeOnly);
435
439
  };
436
440
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specverse/engines",
3
- "version": "6.17.0",
3
+ "version": "6.18.1",
4
4
  "description": "SpecVerse toolchain — parser, inference, realize, generators, AI, registry, bundles",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",