@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.
Files changed (46) hide show
  1. package/dist/ai/analyse-runner.js +1 -1
  2. package/dist/ai/analyse-runner.js.map +1 -1
  3. package/dist/ai/behaviours-runner.js +1 -1
  4. package/dist/ai/behaviours-runner.js.map +1 -1
  5. package/dist/ai/deployment-emitter.d.ts +3 -0
  6. package/dist/ai/deployment-emitter.d.ts.map +1 -1
  7. package/dist/ai/deployment-emitter.js +145 -0
  8. package/dist/ai/deployment-emitter.js.map +1 -1
  9. package/dist/ai/skeleton-emitter.d.ts +1 -1
  10. package/dist/ai/skeleton-emitter.d.ts.map +1 -1
  11. package/dist/ai/skeleton-emitter.js +73 -26
  12. package/dist/ai/skeleton-emitter.js.map +1 -1
  13. package/dist/analyse-prepass/imports-graph.d.ts +274 -0
  14. package/dist/analyse-prepass/imports-graph.d.ts.map +1 -1
  15. package/dist/analyse-prepass/imports-graph.js +770 -0
  16. package/dist/analyse-prepass/imports-graph.js.map +1 -1
  17. package/dist/analyse-prepass/index.d.ts +20 -0
  18. package/dist/analyse-prepass/index.d.ts.map +1 -1
  19. package/dist/analyse-prepass/index.js +17 -0
  20. package/dist/analyse-prepass/index.js.map +1 -1
  21. package/dist/libs/instance-factories/orms/templates/prisma/schema-generator.js +66 -4
  22. package/dist/parser/unified-parser.d.ts.map +1 -1
  23. package/dist/parser/unified-parser.js +103 -0
  24. package/dist/parser/unified-parser.js.map +1 -1
  25. package/dist/realize/index.d.ts.map +1 -1
  26. package/dist/realize/index.js +84 -147
  27. package/dist/realize/index.js.map +1 -1
  28. package/dist/realize/per-action-emitter.d.ts +235 -0
  29. package/dist/realize/per-action-emitter.d.ts.map +1 -0
  30. package/dist/realize/per-action-emitter.js +229 -0
  31. package/dist/realize/per-action-emitter.js.map +1 -0
  32. package/dist/realize/per-action-llm-emit.d.ts +87 -0
  33. package/dist/realize/per-action-llm-emit.d.ts.map +1 -0
  34. package/dist/realize/per-action-llm-emit.js +427 -0
  35. package/dist/realize/per-action-llm-emit.js.map +1 -0
  36. package/dist/realize/per-action-runner.d.ts +127 -0
  37. package/dist/realize/per-action-runner.d.ts.map +1 -0
  38. package/dist/realize/per-action-runner.js +269 -0
  39. package/dist/realize/per-action-runner.js.map +1 -0
  40. package/dist/realize/structural-validator.d.ts +71 -0
  41. package/dist/realize/structural-validator.d.ts.map +1 -0
  42. package/dist/realize/structural-validator.js +167 -0
  43. package/dist/realize/structural-validator.js.map +1 -0
  44. package/libs/instance-factories/orms/templates/prisma/__tests__/schema-generator.test.ts +416 -0
  45. package/libs/instance-factories/orms/templates/prisma/schema-generator.ts +182 -5
  46. 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
- const header = generateHeader(implType);
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 = implType?.configuration?.orm?.provider || 'sqlite';
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
- const prismaType = mapTypeToPrisma(attr.type, attr.dbMapping?.columnType);
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.32.11",
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.16.0",
64
+ "@specverse/assets": "^1.17.0",
65
65
  "@specverse/engines": "^6.29.3",
66
- "@specverse/entities": "^5.3.0",
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",