@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.
Files changed (21) hide show
  1. package/dist/analyse-prepass/adapters/typescript-decorators.d.ts.map +1 -1
  2. package/dist/analyse-prepass/adapters/typescript-decorators.js +112 -34
  3. package/dist/analyse-prepass/adapters/typescript-decorators.js.map +1 -1
  4. package/dist/analyse-prepass/backends/grep-only.d.ts.map +1 -1
  5. package/dist/analyse-prepass/backends/grep-only.js +17 -0
  6. package/dist/analyse-prepass/backends/grep-only.js.map +1 -1
  7. package/dist/analyse-prepass/method-body-walker.d.ts.map +1 -1
  8. package/dist/analyse-prepass/method-body-walker.js +7 -1
  9. package/dist/analyse-prepass/method-body-walker.js.map +1 -1
  10. package/dist/libs/instance-factories/cli/templates/commander/command-generator.js +36 -1
  11. package/dist/libs/instance-factories/orms/templates/prisma/schema-generator.js +51 -3
  12. package/dist/libs/instance-factories/services/templates/prisma/controller-generator.js +7 -1
  13. package/dist/libs/instance-factories/services/templates/prisma/service-generator.js +6 -0
  14. package/libs/instance-factories/applications/templates/react-starter/__tests__/form-body-composer.test.ts +16 -0
  15. package/libs/instance-factories/cli/__tests__/command-generator.test.ts +83 -0
  16. package/libs/instance-factories/cli/templates/commander/command-generator.ts +36 -1
  17. package/libs/instance-factories/orms/templates/prisma/__tests__/schema-generator.test.ts +315 -0
  18. package/libs/instance-factories/orms/templates/prisma/schema-generator.ts +94 -3
  19. package/libs/instance-factories/services/templates/prisma/controller-generator.ts +4 -1
  20. package/libs/instance-factories/services/templates/prisma/service-generator.ts +4 -1
  21. 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(inferredSpec, outputDir);
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(attr: any, model: any): string {
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
- JSON: 'any'
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
- JSON: 'any',
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.35.0",
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.1",
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",