@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.
- package/dist/ai/index.d.ts +2 -0
- package/dist/ai/index.d.ts.map +1 -1
- package/dist/ai/index.js +4 -0
- package/dist/ai/index.js.map +1 -1
- package/dist/ai/library-whitelist.d.ts +81 -0
- package/dist/ai/library-whitelist.d.ts.map +1 -0
- package/dist/ai/library-whitelist.js +251 -0
- package/dist/ai/library-whitelist.js.map +1 -0
- package/dist/libs/instance-factories/applications/templates/generic/backend-package-json-generator.js +34 -14
- package/dist/libs/instance-factories/services/templates/postgres-native/ddl-generator.js +61 -7
- package/dist/libs/instance-factories/services/templates/prisma/ai-behaviors-generator.js +24 -9
- package/libs/instance-factories/applications/templates/generic/backend-package-json-generator.ts +50 -14
- package/libs/instance-factories/services/templates/postgres-native/__tests__/ddl-generator.test.ts +285 -0
- package/libs/instance-factories/services/templates/postgres-native/ddl-generator.ts +140 -24
- package/libs/instance-factories/services/templates/prisma/ai-behaviors-generator.ts +38 -7
- package/package.json +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { matchStep } from "./step-conventions.js";
|
|
2
|
+
import { validateImportWhitelist } from "@specverse/engines/ai";
|
|
2
3
|
import { createHash } from "crypto";
|
|
3
4
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from "fs";
|
|
4
5
|
import { dirname, join } from "path";
|
|
@@ -16,6 +17,11 @@ async function validateTypeScript(code) {
|
|
|
16
17
|
return msg;
|
|
17
18
|
}
|
|
18
19
|
}
|
|
20
|
+
function validateImports(code) {
|
|
21
|
+
const offenders = validateImportWhitelist(code);
|
|
22
|
+
if (offenders.length === 0) return null;
|
|
23
|
+
return `import not in whitelist: ${offenders.join(", ")} (allowed: jsonwebtoken | bcryptjs | uuid | crypto | expr-eval)`;
|
|
24
|
+
}
|
|
19
25
|
async function validateTypeScriptTypes(code) {
|
|
20
26
|
let ts;
|
|
21
27
|
try {
|
|
@@ -75,7 +81,7 @@ async function validateTypeScriptTypes(code) {
|
|
|
75
81
|
return null;
|
|
76
82
|
}
|
|
77
83
|
}
|
|
78
|
-
const PROMPT_VERSION = "9.
|
|
84
|
+
const PROMPT_VERSION = "9.8.0";
|
|
79
85
|
function cacheKey(step, modelName, operationName, functionName, inputs) {
|
|
80
86
|
const payload = JSON.stringify({ step, modelName, operationName, functionName, inputs: [...inputs].sort(), v: PROMPT_VERSION });
|
|
81
87
|
return createHash("sha256").update(payload).digest("hex").slice(0, 16);
|
|
@@ -249,8 +255,9 @@ ${body}
|
|
|
249
255
|
}`;
|
|
250
256
|
const syntaxError = await validateTypeScript(testCode);
|
|
251
257
|
const typeError = syntaxError ? null : await validateTypeScriptTypes(testCode);
|
|
252
|
-
|
|
253
|
-
|
|
258
|
+
const importError = syntaxError || typeError ? null : validateImports(testCode);
|
|
259
|
+
if (syntaxError || typeError || importError) {
|
|
260
|
+
console.warn(` [ai-validate] cached ${functionName} failed validation: ${syntaxError || typeError || importError}`);
|
|
254
261
|
body = null;
|
|
255
262
|
source = "STUB";
|
|
256
263
|
} else {
|
|
@@ -285,13 +292,20 @@ ${body}
|
|
|
285
292
|
source = "AI-INVALID";
|
|
286
293
|
} else {
|
|
287
294
|
const typeError = await validateTypeScriptTypes(testCode);
|
|
288
|
-
|
|
289
|
-
|
|
295
|
+
const importError = typeError ? null : validateImports(testCode);
|
|
296
|
+
if (typeError || importError) {
|
|
297
|
+
console.warn(` [ai-validate] ${functionName} ${typeError ? "type errors: " + typeError : "whitelist violation: " + importError}`);
|
|
290
298
|
try {
|
|
291
|
-
const
|
|
292
|
-
|
|
299
|
+
const errorParts = [];
|
|
300
|
+
if (typeError) errorParts.push(`TypeScript type errors:
|
|
301
|
+
${typeError}`);
|
|
302
|
+
if (importError) errorParts.push(`Import-whitelist violation: ${importError}.
|
|
303
|
+
Only these libraries may be dynamic-imported: jsonwebtoken, bcryptjs, uuid, crypto, expr-eval. Anything else is forbidden \u2014 throw an Error if the step needs an unsupported library.`);
|
|
304
|
+
const retryHint = `Your previous output had problems:
|
|
305
|
+
|
|
306
|
+
${errorParts.join("\n\n")}
|
|
293
307
|
|
|
294
|
-
Fix these specifically \u2014 common causes:
|
|
308
|
+
Fix these specifically \u2014 common type-error causes:
|
|
295
309
|
- RegExp match indices are 'string | undefined'; use non-null assertion or extract to a typed variable
|
|
296
310
|
- Strict null checks: guard or assert before use
|
|
297
311
|
- Don't declare locals you never reference
|
|
@@ -315,7 +329,8 @@ ${retried}
|
|
|
315
329
|
}`;
|
|
316
330
|
const retrySyntaxError = await validateTypeScript(retryCode);
|
|
317
331
|
const retryTypeError = retrySyntaxError ? null : await validateTypeScriptTypes(retryCode);
|
|
318
|
-
|
|
332
|
+
const retryImportError = retrySyntaxError || retryTypeError ? null : validateImports(retryCode);
|
|
333
|
+
if (!retrySyntaxError && !retryTypeError && !retryImportError) {
|
|
319
334
|
body = retried;
|
|
320
335
|
source = "AI-GENERATED";
|
|
321
336
|
cacheWrite(key, body);
|
package/libs/instance-factories/applications/templates/generic/backend-package-json-generator.ts
CHANGED
|
@@ -8,6 +8,20 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { TemplateContext } from '@specverse/types';
|
|
11
|
+
import { predictAiBehaviorLibraries } from '@specverse/engines/ai';
|
|
12
|
+
|
|
13
|
+
// Version pins for the AI-behavior whitelist libs. Single source of
|
|
14
|
+
// truth so the runtime dep + the @types dep stay in sync. Listed
|
|
15
|
+
// here (not in `library-whitelist.ts`) because that module is shared
|
|
16
|
+
// with the validator + spec scanner — those don't care about npm
|
|
17
|
+
// version ranges, only library names.
|
|
18
|
+
const AI_LIBRARY_VERSIONS: Record<string, { runtime: string; types?: string }> = {
|
|
19
|
+
'jsonwebtoken': { runtime: '^9.0.0', types: '^9.0.0' },
|
|
20
|
+
'bcryptjs': { runtime: '^2.4.3', types: '^2.4.0' },
|
|
21
|
+
'uuid': { runtime: '^9.0.0', types: '^9.0.0' },
|
|
22
|
+
'crypto': { runtime: '*' /* Node built-in */ },
|
|
23
|
+
'expr-eval': { runtime: '^2.0.2' },
|
|
24
|
+
};
|
|
11
25
|
|
|
12
26
|
/** Read the manifest's resolved orm name (e.g. "PrismaORM", "MongoDBNativeDriver"). */
|
|
13
27
|
function resolveOrmName(manifest: any): string {
|
|
@@ -108,16 +122,11 @@ export default function generateBackendPackageJson(context: TemplateContext): st
|
|
|
108
122
|
'zod': '^3.22.0',
|
|
109
123
|
'dotenv': '^16.3.0',
|
|
110
124
|
'commander': '^13.0.0',
|
|
111
|
-
// AI-behavior whitelist —
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
// doesn't actually use them.
|
|
117
|
-
'jsonwebtoken': '^9.0.0',
|
|
118
|
-
'bcryptjs': '^2.4.3',
|
|
119
|
-
'uuid': '^9.0.0',
|
|
120
|
-
'expr-eval': '^2.0.2'
|
|
125
|
+
// AI-behavior whitelist deps — only included when the spec's step
|
|
126
|
+
// text actually triggers them (predicted via predictAiBehaviorLibraries).
|
|
127
|
+
// Pre-fix every backend installed all 5 libs unconditionally even
|
|
128
|
+
// though 97% of generated bodies imported nothing. (#43K-B-review)
|
|
129
|
+
...buildAiLibraryDeps(spec, 'runtime')
|
|
121
130
|
},
|
|
122
131
|
|
|
123
132
|
devDependencies: {
|
|
@@ -130,10 +139,9 @@ export default function generateBackendPackageJson(context: TemplateContext): st
|
|
|
130
139
|
'eslint': '^9.0.0',
|
|
131
140
|
'@typescript-eslint/eslint-plugin': '^8.0.0',
|
|
132
141
|
'@typescript-eslint/parser': '^8.0.0',
|
|
133
|
-
// Type definitions for
|
|
134
|
-
|
|
135
|
-
'
|
|
136
|
-
'@types/uuid': '^9.0.0'
|
|
142
|
+
// Type definitions for whichever AI-behavior whitelist libs are
|
|
143
|
+
// included as runtime deps above. Same per-spec gating.
|
|
144
|
+
...buildAiLibraryDeps(spec, 'types')
|
|
137
145
|
},
|
|
138
146
|
|
|
139
147
|
engines: {
|
|
@@ -143,3 +151,31 @@ export default function generateBackendPackageJson(context: TemplateContext): st
|
|
|
143
151
|
|
|
144
152
|
return JSON.stringify(pkg, null, 2);
|
|
145
153
|
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Build the AI-library deps block for the given spec. `kind = 'runtime'`
|
|
157
|
+
* yields the runtime deps (jsonwebtoken etc.); `kind = 'types'` yields
|
|
158
|
+
* the matching @types/* devDeps. Empty object when no whitelist trigger
|
|
159
|
+
* fires in the spec — the realized backend then ships without any of
|
|
160
|
+
* these deps installed (the validator already prevented bodies from
|
|
161
|
+
* importing them, so nothing references them at runtime).
|
|
162
|
+
*
|
|
163
|
+
* `crypto` is a Node built-in, so it never produces a runtime dep
|
|
164
|
+
* entry; if a body uses it, no install is needed.
|
|
165
|
+
*/
|
|
166
|
+
function buildAiLibraryDeps(spec: any, kind: 'runtime' | 'types'): Record<string, string> {
|
|
167
|
+
const out: Record<string, string> = {};
|
|
168
|
+
const predicted = predictAiBehaviorLibraries(spec);
|
|
169
|
+
for (const lib of predicted) {
|
|
170
|
+
const versions = AI_LIBRARY_VERSIONS[lib];
|
|
171
|
+
if (!versions) continue;
|
|
172
|
+
if (kind === 'runtime') {
|
|
173
|
+
// Skip Node built-ins (crypto) — they don't go in package.json deps.
|
|
174
|
+
if (versions.runtime === '*') continue;
|
|
175
|
+
out[lib] = versions.runtime;
|
|
176
|
+
} else if (kind === 'types' && versions.types) {
|
|
177
|
+
out[`@types/${lib}`] = versions.types;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
package/libs/instance-factories/services/templates/postgres-native/__tests__/ddl-generator.test.ts
ADDED
|
@@ -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
|
+
});
|