@specverse/engines 6.32.11 → 6.33.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.
- package/dist/ai/analyse-runner.js +1 -1
- package/dist/ai/analyse-runner.js.map +1 -1
- package/dist/ai/behaviours-runner.js +1 -1
- package/dist/ai/behaviours-runner.js.map +1 -1
- package/dist/ai/deployment-emitter.d.ts +3 -0
- package/dist/ai/deployment-emitter.d.ts.map +1 -1
- package/dist/ai/deployment-emitter.js +145 -0
- package/dist/ai/deployment-emitter.js.map +1 -1
- package/dist/ai/skeleton-emitter.d.ts +1 -1
- package/dist/ai/skeleton-emitter.d.ts.map +1 -1
- package/dist/ai/skeleton-emitter.js +73 -26
- package/dist/ai/skeleton-emitter.js.map +1 -1
- package/dist/analyse-prepass/imports-graph.d.ts +274 -0
- package/dist/analyse-prepass/imports-graph.d.ts.map +1 -1
- package/dist/analyse-prepass/imports-graph.js +770 -0
- package/dist/analyse-prepass/imports-graph.js.map +1 -1
- package/dist/analyse-prepass/index.d.ts +20 -0
- package/dist/analyse-prepass/index.d.ts.map +1 -1
- package/dist/analyse-prepass/index.js +17 -0
- package/dist/analyse-prepass/index.js.map +1 -1
- package/dist/libs/instance-factories/orms/templates/prisma/schema-generator.js +66 -4
- package/dist/parser/unified-parser.d.ts.map +1 -1
- package/dist/parser/unified-parser.js +103 -0
- package/dist/parser/unified-parser.js.map +1 -1
- package/dist/realize/index.d.ts.map +1 -1
- package/dist/realize/index.js +84 -147
- package/dist/realize/index.js.map +1 -1
- package/dist/realize/per-action-emitter.d.ts +235 -0
- package/dist/realize/per-action-emitter.d.ts.map +1 -0
- package/dist/realize/per-action-emitter.js +229 -0
- package/dist/realize/per-action-emitter.js.map +1 -0
- package/dist/realize/per-action-llm-emit.d.ts +87 -0
- package/dist/realize/per-action-llm-emit.d.ts.map +1 -0
- package/dist/realize/per-action-llm-emit.js +427 -0
- package/dist/realize/per-action-llm-emit.js.map +1 -0
- package/dist/realize/per-action-runner.d.ts +127 -0
- package/dist/realize/per-action-runner.d.ts.map +1 -0
- package/dist/realize/per-action-runner.js +269 -0
- package/dist/realize/per-action-runner.js.map +1 -0
- package/dist/realize/structural-validator.d.ts +71 -0
- package/dist/realize/structural-validator.d.ts.map +1 -0
- package/dist/realize/structural-validator.js +167 -0
- package/dist/realize/structural-validator.js.map +1 -0
- package/libs/instance-factories/orms/templates/prisma/__tests__/schema-generator.test.ts +416 -0
- package/libs/instance-factories/orms/templates/prisma/schema-generator.ts +182 -5
- package/package.json +3 -3
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for the Prisma schema generator.
|
|
3
|
+
*
|
|
4
|
+
* Pins the three TODO #53 L1 emission fixes shipped 2026-05-10:
|
|
5
|
+
*
|
|
6
|
+
* 53A — manifest's `storage.database` capability mapping is honoured
|
|
7
|
+
* when emitting the datasource block. PostgreSQL15 produces
|
|
8
|
+
* `provider = "postgresql"`; MongoDB6 produces `"mongodb"`;
|
|
9
|
+
* absent storage falls back to "sqlite" for back-compat.
|
|
10
|
+
*
|
|
11
|
+
* 53B — `*At` field names auto-promote to Prisma `DateTime` even when
|
|
12
|
+
* the spec declares the source type as `String` / `Int`. Avoids
|
|
13
|
+
* the "@updatedAt requires DateTime" Prisma error. Non-timestamp
|
|
14
|
+
* names like `status` / `category` are left alone.
|
|
15
|
+
*
|
|
16
|
+
* 53C — attribute/relationship name collisions on a single model fail
|
|
17
|
+
* loudly with a useful realize-time error rather than silently
|
|
18
|
+
* producing an invalid schema.
|
|
19
|
+
*
|
|
20
|
+
* Tests assert substrings (or thrown-error properties) rather than exact
|
|
21
|
+
* file equality so unrelated formatting tweaks don't cascade.
|
|
22
|
+
*/
|
|
23
|
+
import { describe, it, expect } from 'vitest';
|
|
24
|
+
import generatePrismaSchema from '../schema-generator.js';
|
|
25
|
+
|
|
26
|
+
const simpleModel = {
|
|
27
|
+
name: 'Todo',
|
|
28
|
+
attributes: [
|
|
29
|
+
{ name: 'id', type: 'String', required: true },
|
|
30
|
+
{ name: 'title', type: 'String', required: true },
|
|
31
|
+
],
|
|
32
|
+
relationships: [],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
describe('prisma/schema-generator — 53A storage.database threading', () => {
|
|
36
|
+
it('emits provider = "postgresql" + env-based url when PostgreSQL15 is threaded', () => {
|
|
37
|
+
const storageFactory = {
|
|
38
|
+
name: 'PostgreSQL15',
|
|
39
|
+
technology: { database: 'postgresql' },
|
|
40
|
+
};
|
|
41
|
+
const out = generatePrismaSchema({
|
|
42
|
+
spec: { models: [simpleModel] },
|
|
43
|
+
models: [simpleModel],
|
|
44
|
+
storageFactory,
|
|
45
|
+
} as any);
|
|
46
|
+
expect(out).toContain('provider = "postgresql"');
|
|
47
|
+
expect(out).toContain('url = env("DATABASE_URL")');
|
|
48
|
+
expect(out).not.toContain('"file:./dev.db"');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('emits provider = "mongodb" when MongoDB6 is threaded', () => {
|
|
52
|
+
const storageFactory = {
|
|
53
|
+
name: 'MongoDB6',
|
|
54
|
+
technology: { database: 'mongodb' },
|
|
55
|
+
};
|
|
56
|
+
const out = generatePrismaSchema({
|
|
57
|
+
spec: { models: [simpleModel] },
|
|
58
|
+
models: [simpleModel],
|
|
59
|
+
storageFactory,
|
|
60
|
+
} as any);
|
|
61
|
+
expect(out).toContain('provider = "mongodb"');
|
|
62
|
+
expect(out).toContain('url = env("DATABASE_URL")');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('falls back to factory.name when technology.database is missing', () => {
|
|
66
|
+
// Older factories or post-clone shapes might lose the technology block.
|
|
67
|
+
// The name itself ("PostgreSQL15") still encodes the database, and
|
|
68
|
+
// deriveProviderFromStorage searches it as a secondary signal.
|
|
69
|
+
const storageFactory = { name: 'PostgreSQL15' };
|
|
70
|
+
const out = generatePrismaSchema({
|
|
71
|
+
spec: { models: [simpleModel] },
|
|
72
|
+
models: [simpleModel],
|
|
73
|
+
storageFactory,
|
|
74
|
+
} as any);
|
|
75
|
+
expect(out).toContain('provider = "postgresql"');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('falls back to sqlite default when no storageFactory is threaded', () => {
|
|
79
|
+
const out = generatePrismaSchema({
|
|
80
|
+
spec: { models: [simpleModel] },
|
|
81
|
+
models: [simpleModel],
|
|
82
|
+
} as any);
|
|
83
|
+
expect(out).toContain('provider = "sqlite"');
|
|
84
|
+
expect(out).toContain('url = "file:./dev.db"');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('respects implType.configuration.orm.provider as legacy fallback', () => {
|
|
88
|
+
// Direct-call sites that don't go through realizeAll can still
|
|
89
|
+
// configure provider via the legacy implType path. Pin this so
|
|
90
|
+
// the fix doesn't regress callers we don't control.
|
|
91
|
+
const out = generatePrismaSchema({
|
|
92
|
+
spec: { models: [simpleModel] },
|
|
93
|
+
models: [simpleModel],
|
|
94
|
+
implType: { configuration: { orm: { provider: 'mysql' } } },
|
|
95
|
+
} as any);
|
|
96
|
+
expect(out).toContain('provider = "mysql"');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('threaded storageFactory takes precedence over implType', () => {
|
|
100
|
+
const out = generatePrismaSchema({
|
|
101
|
+
spec: { models: [simpleModel] },
|
|
102
|
+
models: [simpleModel],
|
|
103
|
+
storageFactory: { name: 'PostgreSQL15', technology: { database: 'postgresql' } },
|
|
104
|
+
// Conflicting legacy hint — should be overridden.
|
|
105
|
+
implType: { configuration: { orm: { provider: 'sqlite' } } },
|
|
106
|
+
} as any);
|
|
107
|
+
expect(out).toContain('provider = "postgresql"');
|
|
108
|
+
expect(out).not.toContain('provider = "sqlite"');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('prisma/schema-generator — 53B *At field DateTime promotion', () => {
|
|
113
|
+
it('emits createdAt as DateTime even when spec type is "String"', () => {
|
|
114
|
+
const model = {
|
|
115
|
+
name: 'Article',
|
|
116
|
+
attributes: [
|
|
117
|
+
{ name: 'id', type: 'String', required: true },
|
|
118
|
+
{ name: 'createdAt', type: 'String', required: true },
|
|
119
|
+
],
|
|
120
|
+
relationships: [],
|
|
121
|
+
};
|
|
122
|
+
const out = generatePrismaSchema({
|
|
123
|
+
spec: { models: [model] },
|
|
124
|
+
models: [model],
|
|
125
|
+
} as any);
|
|
126
|
+
// Field-line padding: 15 columns minus name length, then DateTime.
|
|
127
|
+
// We assert the type without spaces so the test doesn't break on
|
|
128
|
+
// formatting tweaks.
|
|
129
|
+
expect(out).toMatch(/createdAt\s+DateTime/);
|
|
130
|
+
expect(out).not.toMatch(/createdAt\s+String/);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('emits updatedAt as DateTime + @updatedAt even when spec type is "String"', () => {
|
|
134
|
+
const model = {
|
|
135
|
+
name: 'Article',
|
|
136
|
+
attributes: [
|
|
137
|
+
{ name: 'id', type: 'String', required: true },
|
|
138
|
+
{ name: 'updatedAt', type: 'String', required: true },
|
|
139
|
+
],
|
|
140
|
+
relationships: [],
|
|
141
|
+
};
|
|
142
|
+
const out = generatePrismaSchema({
|
|
143
|
+
spec: { models: [model] },
|
|
144
|
+
models: [model],
|
|
145
|
+
} as any);
|
|
146
|
+
expect(out).toMatch(/updatedAt\s+DateTime/);
|
|
147
|
+
expect(out).toContain('@updatedAt');
|
|
148
|
+
// Critical: the @updatedAt annotation must NOT appear on a String field.
|
|
149
|
+
expect(out).not.toMatch(/updatedAt\s+String[^\n]*@updatedAt/);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('promotes other *At fields too: publishedAt, expiresAt, deletedAt', () => {
|
|
153
|
+
const model = {
|
|
154
|
+
name: 'Post',
|
|
155
|
+
attributes: [
|
|
156
|
+
{ name: 'id', type: 'String', required: true },
|
|
157
|
+
{ name: 'publishedAt', type: 'String' },
|
|
158
|
+
{ name: 'expiresAt', type: 'String' },
|
|
159
|
+
{ name: 'deletedAt', type: 'String' },
|
|
160
|
+
],
|
|
161
|
+
relationships: [],
|
|
162
|
+
};
|
|
163
|
+
const out = generatePrismaSchema({
|
|
164
|
+
spec: { models: [model] },
|
|
165
|
+
models: [model],
|
|
166
|
+
} as any);
|
|
167
|
+
expect(out).toMatch(/publishedAt\s+DateTime/);
|
|
168
|
+
expect(out).toMatch(/expiresAt\s+DateTime/);
|
|
169
|
+
expect(out).toMatch(/deletedAt\s+DateTime/);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('keeps DateTime fields as DateTime (no-op promotion)', () => {
|
|
173
|
+
const model = {
|
|
174
|
+
name: 'Article',
|
|
175
|
+
attributes: [
|
|
176
|
+
{ name: 'id', type: 'String', required: true },
|
|
177
|
+
{ name: 'updatedAt', type: 'DateTime' },
|
|
178
|
+
],
|
|
179
|
+
relationships: [],
|
|
180
|
+
};
|
|
181
|
+
const out = generatePrismaSchema({
|
|
182
|
+
spec: { models: [model] },
|
|
183
|
+
models: [model],
|
|
184
|
+
} as any);
|
|
185
|
+
expect(out).toMatch(/updatedAt\s+DateTime/);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('promotes Int *At fields to DateTime (unix-millis source pattern)', () => {
|
|
189
|
+
const model = {
|
|
190
|
+
name: 'Article',
|
|
191
|
+
attributes: [
|
|
192
|
+
{ name: 'id', type: 'String', required: true },
|
|
193
|
+
{ name: 'createdAt', type: 'Int' },
|
|
194
|
+
],
|
|
195
|
+
relationships: [],
|
|
196
|
+
};
|
|
197
|
+
const out = generatePrismaSchema({
|
|
198
|
+
spec: { models: [model] },
|
|
199
|
+
models: [model],
|
|
200
|
+
} as any);
|
|
201
|
+
expect(out).toMatch(/createdAt\s+DateTime/);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('does NOT promote non-*At String fields (status / category guard)', () => {
|
|
205
|
+
const model = {
|
|
206
|
+
name: 'Article',
|
|
207
|
+
attributes: [
|
|
208
|
+
{ name: 'id', type: 'String', required: true },
|
|
209
|
+
{ name: 'status', type: 'String' },
|
|
210
|
+
{ name: 'category', type: 'String' },
|
|
211
|
+
{ name: 'description', type: 'String' },
|
|
212
|
+
],
|
|
213
|
+
relationships: [],
|
|
214
|
+
};
|
|
215
|
+
const out = generatePrismaSchema({
|
|
216
|
+
spec: { models: [model] },
|
|
217
|
+
models: [model],
|
|
218
|
+
} as any);
|
|
219
|
+
expect(out).toMatch(/status\s+String/);
|
|
220
|
+
expect(out).toMatch(/category\s+String/);
|
|
221
|
+
expect(out).toMatch(/description\s+String/);
|
|
222
|
+
expect(out).not.toMatch(/status\s+DateTime/);
|
|
223
|
+
expect(out).not.toMatch(/category\s+DateTime/);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('does NOT promote names ending in lowercase "at" without uppercase A', () => {
|
|
227
|
+
// The pattern requires a capital A: bat / cat / hat / chat / format
|
|
228
|
+
// are common false-positives that don't match isTimestampFieldName.
|
|
229
|
+
const model = {
|
|
230
|
+
name: 'Thing',
|
|
231
|
+
attributes: [
|
|
232
|
+
{ name: 'id', type: 'String', required: true },
|
|
233
|
+
{ name: 'format', type: 'String' }, // ends in "at" lowercase
|
|
234
|
+
{ name: 'chat', type: 'String' }, // common-noun
|
|
235
|
+
{ name: 'flat', type: 'String' },
|
|
236
|
+
],
|
|
237
|
+
relationships: [],
|
|
238
|
+
};
|
|
239
|
+
const out = generatePrismaSchema({
|
|
240
|
+
spec: { models: [model] },
|
|
241
|
+
models: [model],
|
|
242
|
+
} as any);
|
|
243
|
+
expect(out).toMatch(/format\s+String/);
|
|
244
|
+
expect(out).toMatch(/chat\s+String/);
|
|
245
|
+
expect(out).toMatch(/flat\s+String/);
|
|
246
|
+
expect(out).not.toContain('DateTime');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('does NOT promote a literal-suffix "Bat" that ends in capital "At"-like sequence', () => {
|
|
250
|
+
// Edge case: someone names a field `wombat` (lowercase) — fine.
|
|
251
|
+
// But what about `Habitat`? Pascal-case (caps lead) we explicitly
|
|
252
|
+
// exclude via the leading-lowercase regex anchor.
|
|
253
|
+
const model = {
|
|
254
|
+
name: 'Animal',
|
|
255
|
+
attributes: [
|
|
256
|
+
{ name: 'id', type: 'String', required: true },
|
|
257
|
+
{ name: 'wombat', type: 'String' },
|
|
258
|
+
],
|
|
259
|
+
relationships: [],
|
|
260
|
+
};
|
|
261
|
+
const out = generatePrismaSchema({
|
|
262
|
+
spec: { models: [model] },
|
|
263
|
+
models: [model],
|
|
264
|
+
} as any);
|
|
265
|
+
// wombat ends in lowercase "at", not "At" — must NOT promote.
|
|
266
|
+
expect(out).toMatch(/wombat\s+String/);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe('prisma/schema-generator — 53C attribute/relationship collision', () => {
|
|
271
|
+
it('throws a clear error when an attribute name collides with a relationship name', () => {
|
|
272
|
+
const saveGameModel = {
|
|
273
|
+
name: 'SaveGame',
|
|
274
|
+
attributes: [
|
|
275
|
+
{ name: 'id', type: 'String', required: true },
|
|
276
|
+
{ name: 'gameState', type: 'String' },
|
|
277
|
+
],
|
|
278
|
+
relationships: [
|
|
279
|
+
{ name: 'gameState', type: 'belongsTo', target: 'GameState' },
|
|
280
|
+
],
|
|
281
|
+
};
|
|
282
|
+
const gameStateModel = {
|
|
283
|
+
name: 'GameState',
|
|
284
|
+
attributes: [{ name: 'id', type: 'String', required: true }],
|
|
285
|
+
relationships: [],
|
|
286
|
+
};
|
|
287
|
+
expect(() =>
|
|
288
|
+
generatePrismaSchema({
|
|
289
|
+
spec: { models: [saveGameModel, gameStateModel] },
|
|
290
|
+
models: [saveGameModel, gameStateModel],
|
|
291
|
+
} as any)
|
|
292
|
+
).toThrow(/SaveGame.*attribute.*relationship.*collision/i);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('error message references the colliding name AND the relationship type/target', () => {
|
|
296
|
+
const model = {
|
|
297
|
+
name: 'SaveGame',
|
|
298
|
+
attributes: [
|
|
299
|
+
{ name: 'id', type: 'String', required: true },
|
|
300
|
+
{ name: 'gameState', type: 'String' },
|
|
301
|
+
],
|
|
302
|
+
relationships: [
|
|
303
|
+
{ name: 'gameState', type: 'belongsTo', target: 'GameState' },
|
|
304
|
+
],
|
|
305
|
+
};
|
|
306
|
+
const target = {
|
|
307
|
+
name: 'GameState',
|
|
308
|
+
attributes: [{ name: 'id', type: 'String', required: true }],
|
|
309
|
+
relationships: [],
|
|
310
|
+
};
|
|
311
|
+
let err: Error | null = null;
|
|
312
|
+
try {
|
|
313
|
+
generatePrismaSchema({
|
|
314
|
+
spec: { models: [model, target] },
|
|
315
|
+
models: [model, target],
|
|
316
|
+
} as any);
|
|
317
|
+
} catch (e) {
|
|
318
|
+
err = e as Error;
|
|
319
|
+
}
|
|
320
|
+
expect(err).not.toBeNull();
|
|
321
|
+
expect(err!.message).toContain('"gameState"');
|
|
322
|
+
expect(err!.message).toContain('belongsTo');
|
|
323
|
+
expect(err!.message).toContain('GameState');
|
|
324
|
+
expect(err!.message).toContain('Rename');
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('detects collision even when the relationship has no explicit name (default-derived)', () => {
|
|
328
|
+
// Default name for a hasMany relation = pluralised target lowercase.
|
|
329
|
+
// If the attribute happens to share that name, it still collides.
|
|
330
|
+
const model = {
|
|
331
|
+
name: 'User',
|
|
332
|
+
attributes: [
|
|
333
|
+
{ name: 'id', type: 'String', required: true },
|
|
334
|
+
{ name: 'posts', type: 'String' }, // shadows default rel name "posts"
|
|
335
|
+
],
|
|
336
|
+
relationships: [
|
|
337
|
+
// No `name:` — generator derives "posts" from pluralize("post")
|
|
338
|
+
{ type: 'hasMany', target: 'Post' },
|
|
339
|
+
],
|
|
340
|
+
};
|
|
341
|
+
const post = {
|
|
342
|
+
name: 'Post',
|
|
343
|
+
attributes: [
|
|
344
|
+
{ name: 'id', type: 'String', required: true },
|
|
345
|
+
],
|
|
346
|
+
relationships: [
|
|
347
|
+
{ type: 'belongsTo', target: 'User' },
|
|
348
|
+
],
|
|
349
|
+
};
|
|
350
|
+
expect(() =>
|
|
351
|
+
generatePrismaSchema({
|
|
352
|
+
spec: { models: [model, post] },
|
|
353
|
+
models: [model, post],
|
|
354
|
+
} as any)
|
|
355
|
+
).toThrow(/posts/);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('proceeds normally when no collision exists (no false positives)', () => {
|
|
359
|
+
const model = {
|
|
360
|
+
name: 'User',
|
|
361
|
+
attributes: [
|
|
362
|
+
{ name: 'id', type: 'String', required: true },
|
|
363
|
+
{ name: 'name', type: 'String' },
|
|
364
|
+
{ name: 'email', type: 'String' },
|
|
365
|
+
],
|
|
366
|
+
relationships: [
|
|
367
|
+
{ name: 'posts', type: 'hasMany', target: 'Post' },
|
|
368
|
+
],
|
|
369
|
+
};
|
|
370
|
+
const post = {
|
|
371
|
+
name: 'Post',
|
|
372
|
+
attributes: [
|
|
373
|
+
{ name: 'id', type: 'String', required: true },
|
|
374
|
+
{ name: 'title', type: 'String' },
|
|
375
|
+
],
|
|
376
|
+
relationships: [
|
|
377
|
+
{ name: 'author', type: 'belongsTo', target: 'User' },
|
|
378
|
+
],
|
|
379
|
+
};
|
|
380
|
+
expect(() =>
|
|
381
|
+
generatePrismaSchema({
|
|
382
|
+
spec: { models: [model, post] },
|
|
383
|
+
models: [model, post],
|
|
384
|
+
} as any)
|
|
385
|
+
).not.toThrow();
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('reports multiple collisions in a single model in one error message', () => {
|
|
389
|
+
const model = {
|
|
390
|
+
name: 'WideModel',
|
|
391
|
+
attributes: [
|
|
392
|
+
{ name: 'id', type: 'String', required: true },
|
|
393
|
+
{ name: 'foo', type: 'String' },
|
|
394
|
+
{ name: 'bar', type: 'String' },
|
|
395
|
+
],
|
|
396
|
+
relationships: [
|
|
397
|
+
{ name: 'foo', type: 'belongsTo', target: 'Foo' },
|
|
398
|
+
{ name: 'bar', type: 'hasOne', target: 'Bar' },
|
|
399
|
+
],
|
|
400
|
+
};
|
|
401
|
+
const foo = { name: 'Foo', attributes: [{ name: 'id', type: 'String' }], relationships: [] };
|
|
402
|
+
const bar = { name: 'Bar', attributes: [{ name: 'id', type: 'String' }], relationships: [] };
|
|
403
|
+
let err: Error | null = null;
|
|
404
|
+
try {
|
|
405
|
+
generatePrismaSchema({
|
|
406
|
+
spec: { models: [model, foo, bar] },
|
|
407
|
+
models: [model, foo, bar],
|
|
408
|
+
} as any);
|
|
409
|
+
} catch (e) {
|
|
410
|
+
err = e as Error;
|
|
411
|
+
}
|
|
412
|
+
expect(err).not.toBeNull();
|
|
413
|
+
expect(err!.message).toContain('"foo"');
|
|
414
|
+
expect(err!.message).toContain('"bar"');
|
|
415
|
+
});
|
|
416
|
+
});
|
|
@@ -19,6 +19,17 @@ export default function generatePrismaSchema(context: TemplateContext): string {
|
|
|
19
19
|
throw new Error('No models found in context for schema generation');
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// 53C — pre-emit collision check.
|
|
23
|
+
// Prisma rejects models with duplicate field names. The same name can
|
|
24
|
+
// surface from two analyse-pipeline outputs (an attribute walker emits
|
|
25
|
+
// `gameState: String`; a relationship walker emits `gameState: belongsTo
|
|
26
|
+
// GameState`) and silently produce a Prisma file that refuses to compile.
|
|
27
|
+
// We fail loudly at realize time with a useful error referencing the
|
|
28
|
+
// colliding names so the spec author knows where to disambiguate.
|
|
29
|
+
for (const model of allModels) {
|
|
30
|
+
detectAttributeRelationshipCollision(model);
|
|
31
|
+
}
|
|
32
|
+
|
|
22
33
|
// Build relation map: track all references to each target model
|
|
23
34
|
// so we can add @relation("name") when a target is referenced multiple times
|
|
24
35
|
const relationMap = buildRelationMap(allModels);
|
|
@@ -39,8 +50,11 @@ export default function generatePrismaSchema(context: TemplateContext): string {
|
|
|
39
50
|
}
|
|
40
51
|
}
|
|
41
52
|
|
|
42
|
-
// Generate header
|
|
43
|
-
|
|
53
|
+
// Generate header — thread the storage-factory choice (53A).
|
|
54
|
+
// realize/index.ts:realizeAll passes the resolved storage factory through
|
|
55
|
+
// `additionalContext.storageFactory`. Falls back to implType for callers
|
|
56
|
+
// that don't thread storage (e.g. direct schema-only generation paths).
|
|
57
|
+
const header = generateHeader(implType, context.storageFactory);
|
|
44
58
|
|
|
45
59
|
// Collector for Prisma enum declarations derived from model lifecycles.
|
|
46
60
|
// Generated as a side-effect of generateModelSchema and emitted at the
|
|
@@ -66,6 +80,78 @@ export default function generatePrismaSchema(context: TemplateContext): string {
|
|
|
66
80
|
return `${header}\n\n${enumBlock}${modelSchemas}`;
|
|
67
81
|
}
|
|
68
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Detect duplicate field names within a single model (53C).
|
|
85
|
+
*
|
|
86
|
+
* Prisma rejects models where the same name is declared twice. Two
|
|
87
|
+
* common collision patterns surface from the analyse pipeline:
|
|
88
|
+
* - An attribute walker emits `gameState: String` AND a relationship
|
|
89
|
+
* walker emits `gameState: belongsTo GameState` on the same model.
|
|
90
|
+
* - Two attribute walkers extract the same field from different
|
|
91
|
+
* facets of the source (TS interface + Zod schema).
|
|
92
|
+
*
|
|
93
|
+
* We detect the relationship-vs-attribute collision specifically because
|
|
94
|
+
* it's the one with a deterministic-looking-but-broken result (Prisma's
|
|
95
|
+
* error message says "field already defined" with no hint about the
|
|
96
|
+
* source). Attribute-vs-attribute duplicates are caught by spec validation
|
|
97
|
+
* earlier in the pipeline.
|
|
98
|
+
*/
|
|
99
|
+
function detectAttributeRelationshipCollision(model: any): void {
|
|
100
|
+
if (!model) return;
|
|
101
|
+
const attributes = Array.isArray(model.attributes)
|
|
102
|
+
? model.attributes
|
|
103
|
+
: Object.values(model.attributes || {});
|
|
104
|
+
const relationships = Array.isArray(model.relationships)
|
|
105
|
+
? model.relationships
|
|
106
|
+
: Object.values(model.relationships || {});
|
|
107
|
+
|
|
108
|
+
const attributeNames = new Set<string>();
|
|
109
|
+
for (const attr of attributes as any[]) {
|
|
110
|
+
if (attr?.name) attributeNames.add(attr.name);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const collisions: Array<{ name: string; relType: string; relTarget: string }> = [];
|
|
114
|
+
for (const rel of relationships as any[]) {
|
|
115
|
+
// Relationships emit a field named after `rel.name` (or a derived default
|
|
116
|
+
// when name is absent). The default for hasMany/manyToMany is the
|
|
117
|
+
// pluralised target lowercase; for belongsTo/hasOne it's the bare target
|
|
118
|
+
// lowercase. We check ALL these surfaces so a spec author who relied on
|
|
119
|
+
// the implicit naming gets the same protection as one who set `name:`.
|
|
120
|
+
const defaultName =
|
|
121
|
+
rel?.type === 'hasMany' || rel?.type === 'manyToMany'
|
|
122
|
+
? pluralize(String(rel?.target || '').toLowerCase())
|
|
123
|
+
: String(rel?.target || '').toLowerCase();
|
|
124
|
+
const candidate = rel?.name || defaultName;
|
|
125
|
+
if (candidate && attributeNames.has(candidate)) {
|
|
126
|
+
collisions.push({
|
|
127
|
+
name: candidate,
|
|
128
|
+
relType: String(rel?.type || 'unknown'),
|
|
129
|
+
relTarget: String(rel?.target || 'unknown'),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (collisions.length > 0) {
|
|
135
|
+
const lines: string[] = [];
|
|
136
|
+
lines.push(
|
|
137
|
+
`Realize error: model "${model.name}" has attribute/relationship name collision(s):`
|
|
138
|
+
);
|
|
139
|
+
for (const c of collisions) {
|
|
140
|
+
lines.push(
|
|
141
|
+
` - "${c.name}" is declared as both an attribute AND a ${c.relType} relationship to ${c.relTarget}`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
lines.push(
|
|
145
|
+
'Rename one to disambiguate (e.g. "' +
|
|
146
|
+
collisions[0].name +
|
|
147
|
+
'Data" for the attribute, keeping "' +
|
|
148
|
+
collisions[0].name +
|
|
149
|
+
'" for the relationship).'
|
|
150
|
+
);
|
|
151
|
+
throw new Error(lines.join('\n'));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
69
155
|
/**
|
|
70
156
|
* Build a relation name map that assigns consistent names to both sides of each relation.
|
|
71
157
|
*
|
|
@@ -273,11 +359,56 @@ function getRelationName(
|
|
|
273
359
|
return `${sourceModel}_${fieldName}`;
|
|
274
360
|
}
|
|
275
361
|
|
|
362
|
+
/**
|
|
363
|
+
* Map a storage factory's `technology.database` (or factory name) to the
|
|
364
|
+
* Prisma datasource provider string. Centralised so both the threaded-
|
|
365
|
+
* factory path and the legacy implType path agree on the mapping.
|
|
366
|
+
*
|
|
367
|
+
* Recognised values produce the canonical Prisma provider string:
|
|
368
|
+
* - "postgresql" / "postgres" → "postgresql"
|
|
369
|
+
* - "mysql" / "mariadb" → "mysql"
|
|
370
|
+
* - "sqlite" → "sqlite"
|
|
371
|
+
* - "mongodb" → "mongodb"
|
|
372
|
+
* - "sqlserver" / "mssql" → "sqlserver"
|
|
373
|
+
* - "cockroachdb" → "cockroachdb"
|
|
374
|
+
*
|
|
375
|
+
* Anything we don't recognise returns undefined so the caller falls back
|
|
376
|
+
* to its existing default rather than emitting an invalid provider string.
|
|
377
|
+
*/
|
|
378
|
+
function deriveProviderFromStorage(storageFactory: any): string | undefined {
|
|
379
|
+
if (!storageFactory) return undefined;
|
|
380
|
+
// Prefer technology.database (canonical: factories ship "postgresql",
|
|
381
|
+
// "mongodb", etc.); fall back to factory.name (e.g. "PostgreSQL15") for
|
|
382
|
+
// older factories or when the technology block was lost in a deep clone.
|
|
383
|
+
const raw = String(
|
|
384
|
+
storageFactory?.technology?.database ?? storageFactory?.name ?? ''
|
|
385
|
+
).toLowerCase();
|
|
386
|
+
if (!raw) return undefined;
|
|
387
|
+
if (raw.includes('postgres') || raw.includes('postgresql')) return 'postgresql';
|
|
388
|
+
if (raw.includes('mariadb') || raw.includes('mysql')) return 'mysql';
|
|
389
|
+
if (raw.includes('sqlite')) return 'sqlite';
|
|
390
|
+
if (raw.includes('mongodb') || raw.includes('mongo')) return 'mongodb';
|
|
391
|
+
if (raw.includes('sqlserver') || raw.includes('mssql')) return 'sqlserver';
|
|
392
|
+
if (raw.includes('cockroachdb') || raw.includes('cockroach')) return 'cockroachdb';
|
|
393
|
+
return undefined;
|
|
394
|
+
}
|
|
395
|
+
|
|
276
396
|
/**
|
|
277
397
|
* Generate Prisma schema header
|
|
398
|
+
*
|
|
399
|
+
* Provider resolution (53A):
|
|
400
|
+
* 1. `storageFactory` threaded from realize/index.ts via additionalContext —
|
|
401
|
+
* the manifest's `storage.database` capability mapping. Highest priority.
|
|
402
|
+
* 2. `implType.configuration.orm.provider` — legacy path retained for
|
|
403
|
+
* direct callers that bypass the storage-resolution step.
|
|
404
|
+
* 3. `"sqlite"` — final fallback so a manifest-less schema-gen still
|
|
405
|
+
* produces a valid (if unrelated) Prisma file.
|
|
278
406
|
*/
|
|
279
|
-
function generateHeader(implType: any): string {
|
|
280
|
-
const provider =
|
|
407
|
+
function generateHeader(implType: any, storageFactory?: any): string {
|
|
408
|
+
const provider =
|
|
409
|
+
deriveProviderFromStorage(storageFactory) ||
|
|
410
|
+
implType?.configuration?.orm?.provider ||
|
|
411
|
+
'sqlite';
|
|
281
412
|
|
|
282
413
|
return `// Generated Prisma schema from SpecVerse specification
|
|
283
414
|
// This is your Prisma schema file
|
|
@@ -379,12 +510,58 @@ function generateModelSchema(
|
|
|
379
510
|
return schema;
|
|
380
511
|
}
|
|
381
512
|
|
|
513
|
+
/**
|
|
514
|
+
* Detect timestamp-shaped field names (53B).
|
|
515
|
+
*
|
|
516
|
+
* Matches `<word>At` where word starts with a lowercase letter:
|
|
517
|
+
* createdAt, updatedAt, publishedAt, expiresAt, deletedAt, joinedAt, ...
|
|
518
|
+
*
|
|
519
|
+
* Excludes:
|
|
520
|
+
* - `at` alone (no preceding word; ambiguous, could be a noun)
|
|
521
|
+
* - `*Bat`, `*Cat`, `*Hat` etc. (the literal letter "at" appearing
|
|
522
|
+
* mid-word, not as a date suffix) — the lowercase-then-At pattern
|
|
523
|
+
* restricts this to camelCase compound names.
|
|
524
|
+
* - Pascal-case `CreatedAt` (we ONLY want the camelCase form Prisma uses)
|
|
525
|
+
*
|
|
526
|
+
* Type-promotion guard (false-positive avoidance): we promote String /
|
|
527
|
+
* Number / DateTime to DateTime, but NOT plainly unrelated types like
|
|
528
|
+
* Boolean, Json, or relation types. Prisma's `@updatedAt` decorator
|
|
529
|
+
* requires DateTime; any other type would fail compilation regardless
|
|
530
|
+
* of the field name.
|
|
531
|
+
*/
|
|
532
|
+
function isTimestampFieldName(name: string): boolean {
|
|
533
|
+
if (!name) return false;
|
|
534
|
+
// Need at least one char before `At` to avoid bare "at" matching.
|
|
535
|
+
// The leading char must be lowercase to lock onto camelCase compounds —
|
|
536
|
+
// this is the convention every Prisma timestamp field follows in the
|
|
537
|
+
// realized output (see autoTimestampFields list a few lines below).
|
|
538
|
+
return /^[a-z][a-zA-Z0-9]*At$/.test(name);
|
|
539
|
+
}
|
|
540
|
+
|
|
382
541
|
/**
|
|
383
542
|
* Generate a field definition
|
|
384
543
|
*/
|
|
385
544
|
function generateField(attr: any, model: any): string {
|
|
386
545
|
const name = attr.name;
|
|
387
|
-
|
|
546
|
+
let prismaType = mapTypeToPrisma(attr.type, attr.dbMapping?.columnType);
|
|
547
|
+
|
|
548
|
+
// 53B — promote `*At` field names to DateTime when the spec/dbMapping
|
|
549
|
+
// produced a non-DateTime type. Prevents the realize-time emission of
|
|
550
|
+
// `createdAt String @default(now())`
|
|
551
|
+
// which Prisma rejects with the (cryptic) "fields marked with @updatedAt
|
|
552
|
+
// must be of type DateTime" error.
|
|
553
|
+
//
|
|
554
|
+
// We only promote from semantically-compatible source types — String
|
|
555
|
+
// (the most common analyse-time miscategorisation), Number/Int (when
|
|
556
|
+
// the source code stored unix-millis as a number), and DateTime
|
|
557
|
+
// (no-op, pinned for clarity). NOT promoted: Boolean, Json, relation
|
|
558
|
+
// types — those couldn't legally be timestamps.
|
|
559
|
+
if (isTimestampFieldName(name) && prismaType !== 'DateTime') {
|
|
560
|
+
if (prismaType === 'String' || prismaType === 'Int' || prismaType === 'BigInt' || prismaType === 'Float') {
|
|
561
|
+
prismaType = 'DateTime';
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
388
565
|
const isOptional = !(attr.required || attr.constraints?.required);
|
|
389
566
|
const isUnique = attr.unique || attr.constraints?.unique;
|
|
390
567
|
const metadata = model?.metadata || {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@specverse/engines",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.33.1",
|
|
4
4
|
"description": "SpecVerse toolchain — parser, inference, realize, generators, AI, registry, bundles",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -61,9 +61,9 @@
|
|
|
61
61
|
"@ai-sdk/anthropic": "^3.0.71",
|
|
62
62
|
"@ai-sdk/openai-compatible": "^2.0.41",
|
|
63
63
|
"@ai-sdk/provider": "^3.0.8",
|
|
64
|
-
"@specverse/assets": "^1.
|
|
64
|
+
"@specverse/assets": "^1.17.0",
|
|
65
65
|
"@specverse/engines": "^6.29.3",
|
|
66
|
-
"@specverse/entities": "^5.
|
|
66
|
+
"@specverse/entities": "^5.4.0",
|
|
67
67
|
"@specverse/runtime": "^5.0.1",
|
|
68
68
|
"@specverse/types": "^5.1.0",
|
|
69
69
|
"ai": "^6.0.168",
|