@specverse/engines 6.35.0 → 6.38.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/analyse-prepass/adapters/typescript-decorators.d.ts.map +1 -1
- package/dist/analyse-prepass/adapters/typescript-decorators.js +112 -34
- package/dist/analyse-prepass/adapters/typescript-decorators.js.map +1 -1
- package/dist/analyse-prepass/backends/grep-only.d.ts.map +1 -1
- package/dist/analyse-prepass/backends/grep-only.js +17 -0
- package/dist/analyse-prepass/backends/grep-only.js.map +1 -1
- package/dist/analyse-prepass/method-body-walker.d.ts.map +1 -1
- package/dist/analyse-prepass/method-body-walker.js +7 -1
- package/dist/analyse-prepass/method-body-walker.js.map +1 -1
- package/dist/libs/instance-factories/cli/templates/commander/command-generator.js +36 -1
- package/dist/libs/instance-factories/orms/templates/prisma/schema-generator.js +51 -3
- package/dist/libs/instance-factories/services/templates/prisma/controller-generator.js +7 -1
- package/dist/libs/instance-factories/services/templates/prisma/service-generator.js +6 -0
- package/libs/instance-factories/applications/templates/react-starter/__tests__/form-body-composer.test.ts +16 -0
- package/libs/instance-factories/cli/__tests__/command-generator.test.ts +83 -0
- package/libs/instance-factories/cli/templates/commander/command-generator.ts +36 -1
- package/libs/instance-factories/orms/templates/prisma/__tests__/schema-generator.test.ts +315 -0
- package/libs/instance-factories/orms/templates/prisma/schema-generator.ts +94 -3
- package/libs/instance-factories/services/templates/prisma/controller-generator.ts +4 -1
- package/libs/instance-factories/services/templates/prisma/service-generator.ts +4 -1
- package/package.json +2 -2
|
@@ -17,6 +17,7 @@ function makeContext(overrides: Partial<EmitContext> = {}): EmitContext {
|
|
|
17
17
|
authorId: { type: 'UUID', required: true }, // belongsTo FK
|
|
18
18
|
externalId: { type: 'UUID', required: false }, // non-FK UUID
|
|
19
19
|
createdAt: { type: 'DateTime', required: false }, // metadata
|
|
20
|
+
metadata: { type: 'Json', required: false }, // #43C — Json type
|
|
20
21
|
},
|
|
21
22
|
relationships: {
|
|
22
23
|
author: { type: 'belongsTo', targetModel: 'Author' },
|
|
@@ -128,6 +129,21 @@ describe('composeFormBody — input type per attribute', () => {
|
|
|
128
129
|
const body = composeFormBody(makeContext());
|
|
129
130
|
expect(body).toMatch(/id="externalId"[\s\S]*type="text"/);
|
|
130
131
|
});
|
|
132
|
+
|
|
133
|
+
it('Json → textarea using formatJsonForInput + parseJsonInput helpers (#43C)', () => {
|
|
134
|
+
// Pre-#43C, a Json field fell through to the default <input type="text">
|
|
135
|
+
// branch — single-line, no parse handling, mangled nested structure
|
|
136
|
+
// on round-trip. The new branch renders <textarea> and delegates
|
|
137
|
+
// read/write formatting to runtime helpers (formatJsonForInput,
|
|
138
|
+
// parseJsonInput from @specverse/runtime/views/core/entity-display)
|
|
139
|
+
// so the emitted JSX stays clean (no inline IIFE that confuses the
|
|
140
|
+
// parser) and the parsing rules live in one tested place.
|
|
141
|
+
const body = composeFormBody(makeContext());
|
|
142
|
+
expect(body).toMatch(/<textarea\s[^>]*id="metadata"/);
|
|
143
|
+
expect(body).not.toMatch(/id="metadata"[\s\S]{0,80}?type="text"/);
|
|
144
|
+
expect(body).toContain('formatJsonForInput((formData as any).metadata)');
|
|
145
|
+
expect(body).toContain("handleChange('metadata', parseJsonInput(e.target.value))");
|
|
146
|
+
});
|
|
131
147
|
});
|
|
132
148
|
|
|
133
149
|
describe('composeFormBody — required markers', () => {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* command-generator (commander) — TODO #15 subtype dispatch test.
|
|
3
|
+
*
|
|
4
|
+
* The realize CLI command was previously taking a `<type>` positional
|
|
5
|
+
* arg and ignoring it: every invocation called realizeAll with the
|
|
6
|
+
* full spec. #15 wires `type` into a per-component section filter so
|
|
7
|
+
* `spv realize controllers <spec>` only regenerates controller outputs.
|
|
8
|
+
*
|
|
9
|
+
* This test exercises the generator with a realize-shaped command spec
|
|
10
|
+
* and asserts the emitted file contains the dispatch logic. End-to-end
|
|
11
|
+
* verification (does spv realize controllers actually skip non-
|
|
12
|
+
* controller sections?) requires a publish; the unit test pins the
|
|
13
|
+
* shape of the emitted handler.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it, expect } from 'vitest';
|
|
17
|
+
import generateCommand from '../templates/commander/command-generator.js';
|
|
18
|
+
|
|
19
|
+
function makeContext() {
|
|
20
|
+
return {
|
|
21
|
+
command: {
|
|
22
|
+
name: 'realize',
|
|
23
|
+
description: 'Generate production code from specification',
|
|
24
|
+
arguments: {
|
|
25
|
+
type: { positional: true, position: 1, required: true, type: 'string' },
|
|
26
|
+
file: { positional: true, position: 2, required: true, type: 'string' },
|
|
27
|
+
},
|
|
28
|
+
flags: {
|
|
29
|
+
output: { type: 'string', shortName: 'o', description: 'Output directory' },
|
|
30
|
+
manifest: { type: 'string', shortName: 'm', description: 'Implementation manifest file' },
|
|
31
|
+
static: { type: 'boolean', description: 'Generate full static frontend (no @specverse/runtime dependency)' },
|
|
32
|
+
estimate: { type: 'boolean', description: 'Print expected output breakdown by realize layer' },
|
|
33
|
+
},
|
|
34
|
+
serviceRef: 'realize',
|
|
35
|
+
},
|
|
36
|
+
} as any;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('cli/commander/command-generator — #15 realize subtype dispatch', () => {
|
|
40
|
+
it('emits ENTITY_SECTIONS guard listing the canonical entity types', () => {
|
|
41
|
+
const out = generateCommand(makeContext());
|
|
42
|
+
expect(out).toContain("'models'");
|
|
43
|
+
expect(out).toContain("'controllers'");
|
|
44
|
+
expect(out).toContain("'services'");
|
|
45
|
+
expect(out).toContain("'views'");
|
|
46
|
+
expect(out).toContain("'events'");
|
|
47
|
+
expect(out).toContain("'deployments'");
|
|
48
|
+
expect(out).toContain("'commands'");
|
|
49
|
+
expect(out).toContain("'measures'");
|
|
50
|
+
expect(out).toContain("'promotions'");
|
|
51
|
+
expect(out).toContain("'conventions'");
|
|
52
|
+
expect(out).toContain("'distributions'");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('rejects unknown subtypes with a clear error and process.exit', () => {
|
|
56
|
+
const out = generateCommand(makeContext());
|
|
57
|
+
expect(out).toMatch(/Unknown realize subtype/);
|
|
58
|
+
expect(out).toContain("process.exit(1)");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('preserves the all-default behaviour by passing inferredSpec through unchanged', () => {
|
|
62
|
+
const out = generateCommand(makeContext());
|
|
63
|
+
// The "all" branch must NOT JSON-clone — it should pass inferredSpec
|
|
64
|
+
// straight to realizeAll. The clone only happens in the filter branch.
|
|
65
|
+
expect(out).toContain("realizeType !== 'all'");
|
|
66
|
+
expect(out).toContain("realizeAll(realizeSpec, outputDir)");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('clones + filters the spec for non-all subtypes', () => {
|
|
70
|
+
const out = generateCommand(makeContext());
|
|
71
|
+
// Deep-clone marker.
|
|
72
|
+
expect(out).toContain('JSON.parse(JSON.stringify(inferredSpec))');
|
|
73
|
+
// Per-component section reset (sections other than the requested
|
|
74
|
+
// type are emptied to their array/object identity element).
|
|
75
|
+
expect(out).toMatch(/section !== realizeType/);
|
|
76
|
+
expect(out).toMatch(/Array\.isArray\(comp\[section\]\)\s*\?\s*\[\]\s*:\s*\{\}/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('logs which subtype is being realized (not just the file path)', () => {
|
|
80
|
+
const out = generateCommand(makeContext());
|
|
81
|
+
expect(out).toMatch(/Realizing.*\+\s*type\s*\+/);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -720,9 +720,44 @@ import type { ParserEngine, InferenceEngine, RealizeEngine } from '@specverse/ty
|
|
|
720
720
|
if (!realizeEngine) { console.error('No realize engine found.'); process.exit(1); }
|
|
721
721
|
await realizeEngine.initialize({ manifestPath: effectiveManifestPath, workingDir: (process.env.SPECVERSE_USER_CWD || process.cwd()) });
|
|
722
722
|
|
|
723
|
+
// #15 — subtype dispatch. \`type\` is the positional arg that was
|
|
724
|
+
// previously captured-but-ignored. \`all\` is the default and runs
|
|
725
|
+
// the full pipeline. Other values name a single entity section
|
|
726
|
+
// (models / controllers / services / views / events / deployments
|
|
727
|
+
// / commands / measures / promotions / conventions / distributions)
|
|
728
|
+
// and we filter the inferred spec to that section per component
|
|
729
|
+
// before handing it to realize. The realize engine iterates each
|
|
730
|
+
// section independently; an empty section is a no-op for that
|
|
731
|
+
// section's generators.
|
|
732
|
+
const ENTITY_SECTIONS = new Set([
|
|
733
|
+
'models', 'controllers', 'services', 'views', 'events',
|
|
734
|
+
'deployments', 'commands', 'measures', 'promotions',
|
|
735
|
+
'conventions', 'distributions',
|
|
736
|
+
]);
|
|
737
|
+
let realizeSpec: any = inferredSpec;
|
|
738
|
+
const realizeType = String(type || 'all').toLowerCase();
|
|
739
|
+
if (realizeType !== 'all') {
|
|
740
|
+
if (!ENTITY_SECTIONS.has(realizeType)) {
|
|
741
|
+
console.error(\`Unknown realize subtype: '\${type}'. Expected 'all' or one of: \` + Array.from(ENTITY_SECTIONS).sort().join(', '));
|
|
742
|
+
process.exit(1);
|
|
743
|
+
}
|
|
744
|
+
realizeSpec = JSON.parse(JSON.stringify(inferredSpec));
|
|
745
|
+
const components: any[] = Array.isArray(realizeSpec?.components)
|
|
746
|
+
? realizeSpec.components
|
|
747
|
+
: Object.values(realizeSpec?.components || {});
|
|
748
|
+
for (const comp of components) {
|
|
749
|
+
if (!comp || typeof comp !== 'object') continue;
|
|
750
|
+
for (const section of ENTITY_SECTIONS) {
|
|
751
|
+
if (section !== realizeType && comp[section] !== undefined) {
|
|
752
|
+
comp[section] = Array.isArray(comp[section]) ? [] : {};
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
723
758
|
const outputDir = resolve((process.env.SPECVERSE_USER_CWD || process.cwd()), options.output || 'generated/code');
|
|
724
759
|
console.log('Realizing ' + type + ' from ' + file + '...');
|
|
725
|
-
await (realizeEngine as any).realizeAll(
|
|
760
|
+
await (realizeEngine as any).realizeAll(realizeSpec, outputDir);
|
|
726
761
|
|
|
727
762
|
// Write dev.specly for runtime mode — only if the manifest
|
|
728
763
|
// actually declares a frontend (app.frontend capability). For
|
|
@@ -414,3 +414,318 @@ describe('prisma/schema-generator — 53C attribute/relationship collision', ()
|
|
|
414
414
|
expect(err!.message).toContain('"bar"');
|
|
415
415
|
});
|
|
416
416
|
});
|
|
417
|
+
|
|
418
|
+
describe('prisma/schema-generator — 43B composite primary key', () => {
|
|
419
|
+
// model.keys: [a, b] (or model.primaryKey: [a, b]) → @@id([a, b]) at
|
|
420
|
+
// the model level. Mirrors postgres-native's parseColumnList contract
|
|
421
|
+
// so the same spec field works across both adapters.
|
|
422
|
+
it('emits @@id([a, b]) when model.keys declares a composite PK', () => {
|
|
423
|
+
const enrolment = {
|
|
424
|
+
name: 'Enrolment',
|
|
425
|
+
keys: ['studentId', 'courseId'],
|
|
426
|
+
attributes: [
|
|
427
|
+
{ name: 'studentId', type: 'String', required: true },
|
|
428
|
+
{ name: 'courseId', type: 'String', required: true },
|
|
429
|
+
{ name: 'enrolledAt', type: 'DateTime' },
|
|
430
|
+
],
|
|
431
|
+
relationships: [],
|
|
432
|
+
};
|
|
433
|
+
const out = generatePrismaSchema({
|
|
434
|
+
spec: { models: [enrolment] },
|
|
435
|
+
models: [enrolment],
|
|
436
|
+
} as any);
|
|
437
|
+
expect(out).toMatch(/@@id\(\[studentId, courseId\]\)/);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('also accepts model.primaryKey as an alias for model.keys', () => {
|
|
441
|
+
const m = {
|
|
442
|
+
name: 'Pair',
|
|
443
|
+
primaryKey: ['a', 'b'],
|
|
444
|
+
attributes: [
|
|
445
|
+
{ name: 'a', type: 'String', required: true },
|
|
446
|
+
{ name: 'b', type: 'String', required: true },
|
|
447
|
+
],
|
|
448
|
+
relationships: [],
|
|
449
|
+
};
|
|
450
|
+
const out = generatePrismaSchema({ spec: { models: [m] }, models: [m] } as any);
|
|
451
|
+
expect(out).toMatch(/@@id\(\[a, b\]\)/);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('does NOT emit @id on the lone id column when composite PK is declared', () => {
|
|
455
|
+
// Without suppression, an `id` attribute would carry column-level @id;
|
|
456
|
+
// Prisma rejects having both column-level @id AND model-level @@id.
|
|
457
|
+
const m = {
|
|
458
|
+
name: 'Compound',
|
|
459
|
+
keys: ['a', 'b'],
|
|
460
|
+
attributes: [
|
|
461
|
+
{ name: 'id', type: 'String', required: true },
|
|
462
|
+
{ name: 'a', type: 'String', required: true },
|
|
463
|
+
{ name: 'b', type: 'String', required: true },
|
|
464
|
+
],
|
|
465
|
+
relationships: [],
|
|
466
|
+
};
|
|
467
|
+
const out = generatePrismaSchema({ spec: { models: [m] }, models: [m] } as any);
|
|
468
|
+
const block = out.match(/model Compound \{[\s\S]*?\n\}/)?.[0] ?? '';
|
|
469
|
+
expect(block).toMatch(/@@id\(\[a, b\]\)/);
|
|
470
|
+
expect(block).not.toMatch(/^\s+id\s+\S+\s+@id/m);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('does NOT emit @@id when keys is a single-column array (degenerate)', () => {
|
|
474
|
+
// [a] is degenerate — column-level @id on `a` is the right shape.
|
|
475
|
+
const m = {
|
|
476
|
+
name: 'Single',
|
|
477
|
+
keys: ['a'],
|
|
478
|
+
attributes: [{ name: 'a', type: 'String', required: true }],
|
|
479
|
+
relationships: [],
|
|
480
|
+
};
|
|
481
|
+
const out = generatePrismaSchema({ spec: { models: [m] }, models: [m] } as any);
|
|
482
|
+
expect(out).not.toMatch(/@@id\(/);
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
describe('prisma/schema-generator — 43D composite uniques + partial indexes', () => {
|
|
487
|
+
it('emits @@unique([a, b]) for one composite-unique declaration', () => {
|
|
488
|
+
// model.unique: ['a', 'b'] → single composite spanning a and b.
|
|
489
|
+
const m = {
|
|
490
|
+
name: 'Uniq',
|
|
491
|
+
unique: ['a', 'b'],
|
|
492
|
+
attributes: [
|
|
493
|
+
{ name: 'id', type: 'String', required: true },
|
|
494
|
+
{ name: 'a', type: 'String', required: true },
|
|
495
|
+
{ name: 'b', type: 'String', required: true },
|
|
496
|
+
],
|
|
497
|
+
relationships: [],
|
|
498
|
+
};
|
|
499
|
+
const out = generatePrismaSchema({ spec: { models: [m] }, models: [m] } as any);
|
|
500
|
+
expect(out).toMatch(/@@unique\(\[a, b\]\)/);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it('emits multiple @@unique blocks when model.unique is array-of-arrays', () => {
|
|
504
|
+
const m = {
|
|
505
|
+
name: 'Multi',
|
|
506
|
+
unique: [['a', 'b'], ['c', 'd']],
|
|
507
|
+
attributes: [
|
|
508
|
+
{ name: 'id', type: 'String', required: true },
|
|
509
|
+
{ name: 'a', type: 'String' },
|
|
510
|
+
{ name: 'b', type: 'String' },
|
|
511
|
+
{ name: 'c', type: 'String' },
|
|
512
|
+
{ name: 'd', type: 'String' },
|
|
513
|
+
],
|
|
514
|
+
relationships: [],
|
|
515
|
+
};
|
|
516
|
+
const out = generatePrismaSchema({ spec: { models: [m] }, models: [m] } as any);
|
|
517
|
+
expect(out).toMatch(/@@unique\(\[a, b\]\)/);
|
|
518
|
+
expect(out).toMatch(/@@unique\(\[c, d\]\)/);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it('emits @@index([col], where: "...") for partial index', () => {
|
|
522
|
+
// attr.index: { where: "deletedAt IS NULL" } → @@index with where clause.
|
|
523
|
+
const m = {
|
|
524
|
+
name: 'Active',
|
|
525
|
+
attributes: [
|
|
526
|
+
{ name: 'id', type: 'String', required: true },
|
|
527
|
+
{
|
|
528
|
+
name: 'email',
|
|
529
|
+
type: 'String',
|
|
530
|
+
index: { where: 'deletedAt IS NULL' },
|
|
531
|
+
},
|
|
532
|
+
],
|
|
533
|
+
relationships: [],
|
|
534
|
+
};
|
|
535
|
+
const out = generatePrismaSchema({ spec: { models: [m] }, models: [m] } as any);
|
|
536
|
+
expect(out).toMatch(/@@index\(\[email\], where: "deletedAt IS NULL"\)/);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it('emits @@unique (not @@index) for partial-unique attr.index.unique=true', () => {
|
|
540
|
+
const m = {
|
|
541
|
+
name: 'ActiveUniq',
|
|
542
|
+
attributes: [
|
|
543
|
+
{ name: 'id', type: 'String', required: true },
|
|
544
|
+
{
|
|
545
|
+
name: 'email',
|
|
546
|
+
type: 'String',
|
|
547
|
+
index: { where: 'deletedAt IS NULL', unique: true },
|
|
548
|
+
},
|
|
549
|
+
],
|
|
550
|
+
relationships: [],
|
|
551
|
+
};
|
|
552
|
+
const out = generatePrismaSchema({ spec: { models: [m] }, models: [m] } as any);
|
|
553
|
+
expect(out).toMatch(/@@unique\(\[email\], where: "deletedAt IS NULL"\)/);
|
|
554
|
+
expect(out).not.toMatch(/@@index\(\[email\], where:/);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it('escapes embedded quotes in where-clause to keep emitted string valid', () => {
|
|
558
|
+
const m = {
|
|
559
|
+
name: 'Quoted',
|
|
560
|
+
attributes: [
|
|
561
|
+
{ name: 'id', type: 'String', required: true },
|
|
562
|
+
{
|
|
563
|
+
name: 'tag',
|
|
564
|
+
type: 'String',
|
|
565
|
+
index: { where: 'tag != "deleted"' },
|
|
566
|
+
},
|
|
567
|
+
],
|
|
568
|
+
relationships: [],
|
|
569
|
+
};
|
|
570
|
+
const out = generatePrismaSchema({ spec: { models: [m] }, models: [m] } as any);
|
|
571
|
+
expect(out).toContain('where: "tag != \\"deleted\\""');
|
|
572
|
+
});
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
describe('prisma/schema-generator — 43C JSON field type pass-through', () => {
|
|
576
|
+
// The prisma side already handles Json correctly (mapTypeToPrisma's
|
|
577
|
+
// typeLower.includes('json') branch). These tests pin that the
|
|
578
|
+
// Json type emits Prisma's `Json` keyword unchanged, with no spurious
|
|
579
|
+
// `String` fallback. The validator/UI generator side of #43C is a
|
|
580
|
+
// separate scope tracked for follow-up.
|
|
581
|
+
it('emits Prisma Json for spec attribute type "Json"', () => {
|
|
582
|
+
const m = {
|
|
583
|
+
name: 'Doc',
|
|
584
|
+
attributes: [
|
|
585
|
+
{ name: 'id', type: 'String', required: true },
|
|
586
|
+
{ name: 'metadata', type: 'Json' },
|
|
587
|
+
],
|
|
588
|
+
relationships: [],
|
|
589
|
+
};
|
|
590
|
+
const out = generatePrismaSchema({ spec: { models: [m] }, models: [m] } as any);
|
|
591
|
+
expect(out).toMatch(/metadata\s+Json/);
|
|
592
|
+
expect(out).not.toMatch(/metadata\s+String/);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it('emits Prisma Json for spec attribute type "JSON" (case-insensitive)', () => {
|
|
596
|
+
const m = {
|
|
597
|
+
name: 'Doc',
|
|
598
|
+
attributes: [
|
|
599
|
+
{ name: 'id', type: 'String', required: true },
|
|
600
|
+
{ name: 'payload', type: 'JSON' },
|
|
601
|
+
],
|
|
602
|
+
relationships: [],
|
|
603
|
+
};
|
|
604
|
+
const out = generatePrismaSchema({ spec: { models: [m] }, models: [m] } as any);
|
|
605
|
+
expect(out).toMatch(/payload\s+Json/);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it('emits Prisma Json when dbMapping.columnType is JSONB', () => {
|
|
609
|
+
const m = {
|
|
610
|
+
name: 'Doc',
|
|
611
|
+
attributes: [
|
|
612
|
+
{ name: 'id', type: 'String', required: true },
|
|
613
|
+
{ name: 'payload', type: 'String', dbMapping: { columnType: 'JSONB' } },
|
|
614
|
+
],
|
|
615
|
+
relationships: [],
|
|
616
|
+
};
|
|
617
|
+
const out = generatePrismaSchema({ spec: { models: [m] }, models: [m] } as any);
|
|
618
|
+
expect(out).toMatch(/payload\s+Json/);
|
|
619
|
+
});
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
describe('prisma/schema-generator — 33 dual-declaration (attribute + lifecycle on same field)', () => {
|
|
623
|
+
// When a model declares a field as BOTH an attribute (`status: String`)
|
|
624
|
+
// AND a lifecycle (`lifecycles.status.flow: ...`), pre-#33 the generator
|
|
625
|
+
// double-emitted — the field landed twice in the Prisma schema, which
|
|
626
|
+
// Prisma rejects with `Field "status" is already defined on model "X"`.
|
|
627
|
+
//
|
|
628
|
+
// The current generator dedupes at line 475 of schema-generator.ts:
|
|
629
|
+
// when `attributes.some(a => a.name === fieldName)` is true, the
|
|
630
|
+
// lifecycle emission is skipped entirely. The attribute wins; the
|
|
631
|
+
// lifecycle's state enum is dropped.
|
|
632
|
+
//
|
|
633
|
+
// These tests pin that dedup so a future change can't silently regress
|
|
634
|
+
// to the double-emit shape that idle-meta hit on 2026-04-29.
|
|
635
|
+
|
|
636
|
+
it('does NOT double-emit when attribute and lifecycle share a name (status)', () => {
|
|
637
|
+
const job = {
|
|
638
|
+
name: 'Job',
|
|
639
|
+
attributes: [
|
|
640
|
+
{ name: 'id', type: 'String', required: true },
|
|
641
|
+
{ name: 'title', type: 'String', required: true },
|
|
642
|
+
{ name: 'status', type: 'String' },
|
|
643
|
+
],
|
|
644
|
+
lifecycles: [
|
|
645
|
+
{ name: 'status', states: ['draft', 'published', 'archived'] },
|
|
646
|
+
],
|
|
647
|
+
relationships: [],
|
|
648
|
+
};
|
|
649
|
+
const out = generatePrismaSchema({
|
|
650
|
+
spec: { models: [job] },
|
|
651
|
+
models: [job],
|
|
652
|
+
} as any);
|
|
653
|
+
// Find the Job model block and count `status` field declarations.
|
|
654
|
+
const jobBlock = out.match(/model Job \{[\s\S]*?\n\}/)?.[0] ?? '';
|
|
655
|
+
const statusFieldDecls = jobBlock.match(/^\s+status\s+/gm) ?? [];
|
|
656
|
+
expect(statusFieldDecls.length).toBe(1);
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
it('attribute wins over lifecycle when both declare the same field', () => {
|
|
660
|
+
// Attribute is plain String; lifecycle would have provided an enum.
|
|
661
|
+
// Current behaviour: attribute wins (preserves the type the user
|
|
662
|
+
// declared explicitly). Documented as the precedence — changing to
|
|
663
|
+
// "lifecycle wins" would be a future migration.
|
|
664
|
+
const job = {
|
|
665
|
+
name: 'Job',
|
|
666
|
+
attributes: [
|
|
667
|
+
{ name: 'id', type: 'String', required: true },
|
|
668
|
+
{ name: 'status', type: 'String' },
|
|
669
|
+
],
|
|
670
|
+
lifecycles: [
|
|
671
|
+
{ name: 'status', states: ['draft', 'open'] },
|
|
672
|
+
],
|
|
673
|
+
relationships: [],
|
|
674
|
+
};
|
|
675
|
+
const out = generatePrismaSchema({
|
|
676
|
+
spec: { models: [job] },
|
|
677
|
+
models: [job],
|
|
678
|
+
} as any);
|
|
679
|
+
const jobBlock = out.match(/model Job \{[\s\S]*?\n\}/)?.[0] ?? '';
|
|
680
|
+
// The status field carries the attribute's String type, NOT the
|
|
681
|
+
// lifecycle-derived JobStatus enum.
|
|
682
|
+
expect(jobBlock).toMatch(/status\s+String/);
|
|
683
|
+
expect(jobBlock).not.toMatch(/status\s+JobStatus/);
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
it('lifecycle is emitted as enum when no attribute conflicts (control case)', () => {
|
|
687
|
+
// Sanity: when there's NO attribute on the same name, the lifecycle's
|
|
688
|
+
// enum DOES land in the schema. Confirms the dedup is name-scoped,
|
|
689
|
+
// not blanket-disabling lifecycle emission.
|
|
690
|
+
const job = {
|
|
691
|
+
name: 'Job',
|
|
692
|
+
attributes: [
|
|
693
|
+
{ name: 'id', type: 'String', required: true },
|
|
694
|
+
{ name: 'title', type: 'String', required: true },
|
|
695
|
+
],
|
|
696
|
+
lifecycles: [
|
|
697
|
+
{ name: 'status', states: ['draft', 'open', 'closed'] },
|
|
698
|
+
],
|
|
699
|
+
relationships: [],
|
|
700
|
+
};
|
|
701
|
+
const out = generatePrismaSchema({
|
|
702
|
+
spec: { models: [job] },
|
|
703
|
+
models: [job],
|
|
704
|
+
} as any);
|
|
705
|
+
expect(out).toMatch(/status\s+JobStatus\s+@default\(draft\)/);
|
|
706
|
+
expect(out).toMatch(/enum JobStatus\s*\{/);
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
it('produces valid Prisma — no duplicate field declaration error possible', () => {
|
|
710
|
+
// The whole point: pre-#33 this combo produced invalid Prisma. Now
|
|
711
|
+
// it produces exactly one `status` line. Use a regex that would
|
|
712
|
+
// catch the bug shape: two `status\s+` lines on consecutive lines
|
|
713
|
+
// inside the same model block.
|
|
714
|
+
const job = {
|
|
715
|
+
name: 'Job',
|
|
716
|
+
attributes: [
|
|
717
|
+
{ name: 'id', type: 'String', required: true },
|
|
718
|
+
{ name: 'status', type: 'String' },
|
|
719
|
+
],
|
|
720
|
+
lifecycles: [
|
|
721
|
+
{ name: 'status', states: ['draft', 'open'] },
|
|
722
|
+
],
|
|
723
|
+
relationships: [],
|
|
724
|
+
};
|
|
725
|
+
const out = generatePrismaSchema({
|
|
726
|
+
spec: { models: [job] },
|
|
727
|
+
models: [job],
|
|
728
|
+
} as any);
|
|
729
|
+
expect(out).not.toMatch(/^\s+status\s+\S+.*\n\s+status\s+/m);
|
|
730
|
+
});
|
|
731
|
+
});
|
|
@@ -448,9 +448,15 @@ function generateModelSchema(
|
|
|
448
448
|
? model.relationships
|
|
449
449
|
: Object.values(model.relationships || {});
|
|
450
450
|
|
|
451
|
+
// 43B prep — when a composite PK is declared, the column-level @id
|
|
452
|
+
// directive must NOT appear on the lone `id` column (Prisma rejects
|
|
453
|
+
// having both forms). Compute once and thread into generateField.
|
|
454
|
+
const compositePkForFields = parseColumnList(model.keys ?? model.primaryKey);
|
|
455
|
+
const hasCompositePk = compositePkForFields.length > 1;
|
|
456
|
+
|
|
451
457
|
// Add attributes
|
|
452
458
|
attributes.forEach((attr: any) => {
|
|
453
|
-
schema += ` ${generateField(attr, model)}\n`;
|
|
459
|
+
schema += ` ${generateField(attr, model, { suppressColumnId: hasCompositePk })}\n`;
|
|
454
460
|
});
|
|
455
461
|
|
|
456
462
|
// Add lifecycle status fields (if model has lifecycles).
|
|
@@ -505,11 +511,87 @@ function generateModelSchema(
|
|
|
505
511
|
}
|
|
506
512
|
}
|
|
507
513
|
|
|
514
|
+
// 43B — composite primary key. Spec author writes `model.keys: [a, b]`
|
|
515
|
+
// (or `model.primaryKey: [a, b]`) → Prisma `@@id([a, b])` block at the
|
|
516
|
+
// end of the model. Mirrors postgres-native's parseColumnList shape so
|
|
517
|
+
// both adapters honour the same spec field.
|
|
518
|
+
const compositePk = parseColumnList(model.keys ?? model.primaryKey);
|
|
519
|
+
if (compositePk.length > 1) {
|
|
520
|
+
schema += ` @@id([${compositePk.join(', ')}])\n`;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// 43D — composite unique constraints. `model.unique: [[a, b]]` → one
|
|
524
|
+
// `@@unique([a, b])` per inner array. Single-column uniqueness stays at
|
|
525
|
+
// the attribute level (`attr.unique: true`) — same convention as pg-native.
|
|
526
|
+
const compositeUniques = parseCompositeUnique(model.unique);
|
|
527
|
+
for (const cols of compositeUniques) {
|
|
528
|
+
if (cols.length > 0) {
|
|
529
|
+
schema += ` @@unique([${cols.join(', ')}])\n`;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// 43D — partial indexes. Prisma 5+ accepts `where:` on `@@index`. Spec
|
|
534
|
+
// shape: `attr.index: { where: "...", unique?: bool }` produces
|
|
535
|
+
// `@@index([attrName], where: "...")` (or `@@unique` when unique=true).
|
|
536
|
+
for (const attr of attributes as any[]) {
|
|
537
|
+
if (!attr?.index || typeof attr.index !== 'object') continue;
|
|
538
|
+
const where = typeof attr.index.where === 'string' ? attr.index.where : null;
|
|
539
|
+
if (!where) continue; // plain (non-partial) indexes go through @unique / column-level @@index
|
|
540
|
+
const isUnique = attr.index.unique === true;
|
|
541
|
+
const directive = isUnique ? '@@unique' : '@@index';
|
|
542
|
+
// Where-clauses are SQL fragments; wrap in single quotes after escaping.
|
|
543
|
+
const escaped = where.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
544
|
+
schema += ` ${directive}([${attr.name}], where: "${escaped}")\n`;
|
|
545
|
+
}
|
|
546
|
+
|
|
508
547
|
schema += `}`;
|
|
509
548
|
|
|
510
549
|
return schema;
|
|
511
550
|
}
|
|
512
551
|
|
|
552
|
+
// ── Composite-key + composite-unique parsers (mirror pg-native) ─────────────
|
|
553
|
+
|
|
554
|
+
/** Parse `model.keys` / `model.primaryKey`. Accepts:
|
|
555
|
+
* - undefined / null → []
|
|
556
|
+
* - string → [string]
|
|
557
|
+
* - string[] → as-is
|
|
558
|
+
* - string[][] → flatten the first row (defensive)
|
|
559
|
+
* Mirrors postgres-native's parseColumnList so the same spec field
|
|
560
|
+
* works across both adapters.
|
|
561
|
+
*/
|
|
562
|
+
function parseColumnList(raw: any): string[] {
|
|
563
|
+
if (raw === undefined || raw === null) return [];
|
|
564
|
+
if (typeof raw === 'string') return [raw];
|
|
565
|
+
if (!Array.isArray(raw)) return [];
|
|
566
|
+
if (raw.length === 0) return [];
|
|
567
|
+
if (typeof raw[0] === 'string') {
|
|
568
|
+
return raw.filter((x: any): x is string => typeof x === 'string');
|
|
569
|
+
}
|
|
570
|
+
if (Array.isArray(raw[0])) {
|
|
571
|
+
return (raw[0] as any[]).filter((x: any): x is string => typeof x === 'string');
|
|
572
|
+
}
|
|
573
|
+
return [];
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** Parse `model.unique`. Accepts:
|
|
577
|
+
* - undefined / null → []
|
|
578
|
+
* - string[] → [[...]] (single composite spanning the listed columns)
|
|
579
|
+
* - string[][] → as-is (multiple composites)
|
|
580
|
+
*/
|
|
581
|
+
function parseCompositeUnique(raw: any): string[][] {
|
|
582
|
+
if (raw === undefined || raw === null) return [];
|
|
583
|
+
if (!Array.isArray(raw) || raw.length === 0) return [];
|
|
584
|
+
if (typeof raw[0] === 'string') {
|
|
585
|
+
return [raw.filter((x: any): x is string => typeof x === 'string')];
|
|
586
|
+
}
|
|
587
|
+
if (Array.isArray(raw[0])) {
|
|
588
|
+
return (raw as any[][])
|
|
589
|
+
.map((row) => row.filter((x: any): x is string => typeof x === 'string'))
|
|
590
|
+
.filter((row) => row.length > 0);
|
|
591
|
+
}
|
|
592
|
+
return [];
|
|
593
|
+
}
|
|
594
|
+
|
|
513
595
|
/**
|
|
514
596
|
* Detect timestamp-shaped field names (53B).
|
|
515
597
|
*
|
|
@@ -540,8 +622,17 @@ function isTimestampFieldName(name: string): boolean {
|
|
|
540
622
|
|
|
541
623
|
/**
|
|
542
624
|
* Generate a field definition
|
|
625
|
+
*
|
|
626
|
+
* `opts.suppressColumnId` (43B): when the model declares a composite
|
|
627
|
+
* primary key (`model.keys: [a, b]`), the `id` column — if present —
|
|
628
|
+
* must NOT carry `@id` because the model-level `@@id([a, b])` block
|
|
629
|
+
* already declares the PK. Prisma rejects having both forms.
|
|
543
630
|
*/
|
|
544
|
-
function generateField(
|
|
631
|
+
function generateField(
|
|
632
|
+
attr: any,
|
|
633
|
+
model: any,
|
|
634
|
+
opts: { suppressColumnId?: boolean } = {},
|
|
635
|
+
): string {
|
|
545
636
|
const name = attr.name;
|
|
546
637
|
let prismaType = mapTypeToPrisma(attr.type, attr.dbMapping?.columnType);
|
|
547
638
|
|
|
@@ -571,7 +662,7 @@ function generateField(attr: any, model: any): string {
|
|
|
571
662
|
let hasDefault = false;
|
|
572
663
|
|
|
573
664
|
// Primary key detection
|
|
574
|
-
if (name.toLowerCase() === 'id') {
|
|
665
|
+
if (name.toLowerCase() === 'id' && !opts.suppressColumnId) {
|
|
575
666
|
modifiers += ' @id';
|
|
576
667
|
if (metadata.id === 'uuid' || metadata.id === 'auto' || prismaType === 'String') {
|
|
577
668
|
modifiers += ' @default(uuid())';
|
|
@@ -445,7 +445,10 @@ function mapTypeToTypeScript(type: string): string {
|
|
|
445
445
|
Date: 'Date',
|
|
446
446
|
DateTime: 'Date',
|
|
447
447
|
UUID: 'string',
|
|
448
|
-
|
|
448
|
+
Json: 'any', // #43C: SpecVerse canonical casing → Prisma's Json
|
|
449
|
+
JSON: 'any', // legacy/alt casing
|
|
450
|
+
Jsonb: 'any', // Postgres-native variant
|
|
451
|
+
JSONB: 'any'
|
|
449
452
|
};
|
|
450
453
|
|
|
451
454
|
return typeMap[type] || 'any';
|
|
@@ -355,7 +355,10 @@ function mapTypeToTypeScript(type: string): string {
|
|
|
355
355
|
Date: 'Date',
|
|
356
356
|
DateTime: 'Date',
|
|
357
357
|
UUID: 'string',
|
|
358
|
-
|
|
358
|
+
Json: 'any', // #43C: SpecVerse canonical casing → Prisma's Json
|
|
359
|
+
JSON: 'any', // legacy/alt casing
|
|
360
|
+
Jsonb: 'any', // Postgres-native variant — same TS shape
|
|
361
|
+
JSONB: 'any',
|
|
359
362
|
Array: 'any[]',
|
|
360
363
|
Object: 'Record<string, any>'
|
|
361
364
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@specverse/engines",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.38.0",
|
|
4
4
|
"description": "SpecVerse toolchain — parser, inference, realize, generators, AI, registry, bundles",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"@specverse/assets": "^1.17.0",
|
|
65
65
|
"@specverse/engines": "^6.29.3",
|
|
66
66
|
"@specverse/entities": "^5.4.0",
|
|
67
|
-
"@specverse/runtime": "^5.0
|
|
67
|
+
"@specverse/runtime": "^5.1.0",
|
|
68
68
|
"@specverse/types": "^5.2.0",
|
|
69
69
|
"ai": "^6.0.168",
|
|
70
70
|
"ajv": "^8.17.0",
|