@specverse/engines 4.2.0 → 4.2.2

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 (87) hide show
  1. package/assets/templates/default/specs/main.specly +65 -0
  2. package/dist/libs/instance-factories/CURVED-INTERFACE.md +278 -0
  3. package/dist/libs/instance-factories/README.md +73 -0
  4. package/dist/libs/instance-factories/applications/README.md +51 -0
  5. package/dist/libs/instance-factories/applications/generic-app.yaml +52 -0
  6. package/dist/libs/instance-factories/applications/react-app-runtime.yaml +139 -0
  7. package/dist/libs/instance-factories/applications/react-app-starter.yaml +143 -0
  8. package/dist/libs/instance-factories/applications/templates/react/env-example-generator.js +24 -2
  9. package/dist/libs/instance-factories/applications/templates/react/vite-config-generator.js +54 -33
  10. package/dist/libs/instance-factories/applications/templates/react-starter/README.md +211 -0
  11. package/dist/libs/instance-factories/applications/templates/react-starter/api-types-starter-generator.js +69 -0
  12. package/dist/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.js +1 -1
  13. package/dist/libs/instance-factories/applications/templates/react-starter/belongs-to.js +40 -0
  14. package/dist/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.js +11 -3
  15. package/dist/libs/instance-factories/applications/templates/react-starter/detail-body-composer.js +18 -16
  16. package/dist/libs/instance-factories/applications/templates/react-starter/form-body-composer.js +50 -23
  17. package/dist/libs/instance-factories/applications/templates/react-starter/helpers-emitter.js +9 -3
  18. package/dist/libs/instance-factories/applications/templates/react-starter/list-body-composer.js +17 -7
  19. package/dist/libs/instance-factories/applications/templates/react-starter/orchestrator.js +16 -5
  20. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/dashboard.tsx.template +49 -0
  21. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +96 -0
  22. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +116 -0
  23. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +74 -0
  24. package/dist/libs/instance-factories/applications/templates/react-starter/use-api-hooks-starter-generator.js +95 -0
  25. package/dist/libs/instance-factories/applications/templates/react-starter/view-emitter.js +26 -1
  26. package/dist/libs/instance-factories/archived/fastify-prisma.yaml +104 -0
  27. package/dist/libs/instance-factories/cli/README.md +43 -0
  28. package/dist/libs/instance-factories/cli/commander-js.yaml +55 -0
  29. package/dist/libs/instance-factories/cli/templates/commander/command-generator.js +49 -1
  30. package/dist/libs/instance-factories/communication/README.md +47 -0
  31. package/dist/libs/instance-factories/communication/event-emitter.yaml +60 -0
  32. package/dist/libs/instance-factories/communication/rabbitmq-events.yaml +87 -0
  33. package/dist/libs/instance-factories/controllers/README.md +42 -0
  34. package/dist/libs/instance-factories/controllers/fastify.yaml +139 -0
  35. package/dist/libs/instance-factories/controllers/templates/fastify/server-generator.js +29 -2
  36. package/dist/libs/instance-factories/infrastructure/README.md +29 -0
  37. package/dist/libs/instance-factories/infrastructure/docker-k8s.yaml +61 -0
  38. package/dist/libs/instance-factories/orms/README.md +54 -0
  39. package/dist/libs/instance-factories/orms/prisma.yaml +89 -0
  40. package/dist/libs/instance-factories/orms/templates/prisma/schema-generator.js +2 -2
  41. package/dist/libs/instance-factories/scaffolding/README.md +49 -0
  42. package/dist/libs/instance-factories/scaffolding/generic-scaffold.yaml +65 -0
  43. package/dist/libs/instance-factories/sdks/README.md +28 -0
  44. package/dist/libs/instance-factories/sdks/python-sdk.yaml +66 -0
  45. package/dist/libs/instance-factories/sdks/typescript-sdk.yaml +59 -0
  46. package/dist/libs/instance-factories/services/README.md +55 -0
  47. package/dist/libs/instance-factories/services/prisma-services.yaml +71 -0
  48. package/dist/libs/instance-factories/storage/README.md +34 -0
  49. package/dist/libs/instance-factories/storage/mongodb.yaml +79 -0
  50. package/dist/libs/instance-factories/storage/postgresql.yaml +75 -0
  51. package/dist/libs/instance-factories/storage/redis.yaml +79 -0
  52. package/dist/libs/instance-factories/testing/README.md +40 -0
  53. package/dist/libs/instance-factories/testing/vitest-tests.yaml +63 -0
  54. package/dist/libs/instance-factories/tools/README.md +70 -0
  55. package/dist/libs/instance-factories/tools/mcp.yaml +36 -0
  56. package/dist/libs/instance-factories/tools/vscode.yaml +35 -0
  57. package/dist/libs/instance-factories/validation/README.md +38 -0
  58. package/dist/libs/instance-factories/validation/zod.yaml +56 -0
  59. package/dist/realize/engines/code-generator.d.ts.map +1 -1
  60. package/dist/realize/engines/code-generator.js +3 -0
  61. package/dist/realize/engines/code-generator.js.map +1 -1
  62. package/libs/instance-factories/applications/react-app-starter.yaml +10 -17
  63. package/libs/instance-factories/applications/templates/react/env-example-generator.ts +24 -2
  64. package/libs/instance-factories/applications/templates/react/vite-config-generator.ts +54 -33
  65. package/libs/instance-factories/applications/templates/react-starter/__tests__/detail-body-composer.test.ts +5 -4
  66. package/libs/instance-factories/applications/templates/react-starter/__tests__/form-body-composer.test.ts +18 -5
  67. package/libs/instance-factories/applications/templates/react-starter/__tests__/orchestrator.test.ts +83 -62
  68. package/libs/instance-factories/applications/templates/react-starter/api-types-starter-generator.ts +98 -0
  69. package/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.ts +1 -1
  70. package/libs/instance-factories/applications/templates/react-starter/belongs-to.ts +82 -0
  71. package/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.ts +20 -5
  72. package/libs/instance-factories/applications/templates/react-starter/detail-body-composer.ts +33 -33
  73. package/libs/instance-factories/applications/templates/react-starter/form-body-composer.ts +107 -30
  74. package/libs/instance-factories/applications/templates/react-starter/helpers-emitter.ts +9 -3
  75. package/libs/instance-factories/applications/templates/react-starter/list-body-composer.ts +34 -8
  76. package/libs/instance-factories/applications/templates/react-starter/orchestrator.ts +41 -26
  77. package/libs/instance-factories/applications/templates/react-starter/skeletons/dashboard.tsx.template +2 -0
  78. package/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +2 -0
  79. package/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +2 -0
  80. package/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +2 -0
  81. package/libs/instance-factories/applications/templates/react-starter/use-api-hooks-starter-generator.ts +124 -0
  82. package/libs/instance-factories/applications/templates/react-starter/view-emitter.ts +58 -0
  83. package/libs/instance-factories/cli/templates/commander/command-generator.ts +49 -1
  84. package/libs/instance-factories/controllers/fastify.yaml +7 -0
  85. package/libs/instance-factories/controllers/templates/fastify/server-generator.ts +36 -2
  86. package/libs/instance-factories/orms/templates/prisma/schema-generator.ts +11 -4
  87. package/package.json +2 -1
@@ -0,0 +1,98 @@
1
+ /**
2
+ * API types generator for ReactAppStarter
3
+ *
4
+ * Emits per-model TypeScript interfaces that the starter's view
5
+ * skeletons import (`import type { Task } from '../types/api'`). The
6
+ * shape mirrors the model's attributes as declared in the spec. No
7
+ * runtime-specific types (those live in ReactAppRuntime's api-types
8
+ * generator).
9
+ */
10
+
11
+ export interface ApiTypesStarterContext {
12
+ spec: {
13
+ models?: Record<string, any>;
14
+ };
15
+ manifest?: unknown;
16
+ }
17
+
18
+ /**
19
+ * Map SpecVerse attribute types to TypeScript types.
20
+ *
21
+ * Strings vs unions: if the attribute has a `values` enum (e.g.
22
+ * status: String values=[draft,published]), emit a union type.
23
+ */
24
+ function mapAttrType(attr: any): string {
25
+ const t = typeof attr === 'string'
26
+ ? attr.split(/\s+/)[0]
27
+ : attr?.type;
28
+ const values = attr?.values;
29
+ if (Array.isArray(values) && values.length > 0) {
30
+ return values.map((v: string) => JSON.stringify(v)).join(' | ');
31
+ }
32
+ switch ((t || '').toLowerCase()) {
33
+ case 'string':
34
+ case 'text':
35
+ case 'uuid':
36
+ case 'email':
37
+ case 'url':
38
+ return 'string';
39
+ case 'int':
40
+ case 'integer':
41
+ case 'float':
42
+ case 'number':
43
+ case 'decimal':
44
+ return 'number';
45
+ case 'boolean':
46
+ case 'bool':
47
+ return 'boolean';
48
+ case 'datetime':
49
+ case 'date':
50
+ case 'time':
51
+ case 'timestamp':
52
+ return 'string';
53
+ case 'json':
54
+ return 'Record<string, unknown>';
55
+ default:
56
+ return 'unknown';
57
+ }
58
+ }
59
+
60
+ function isRequired(attr: any): boolean {
61
+ if (typeof attr === 'string') return /\brequired\b/.test(attr);
62
+ return !!attr?.required;
63
+ }
64
+
65
+ export async function generate(context: ApiTypesStarterContext): Promise<string> {
66
+ const models = context.spec.models ?? {};
67
+ const modelNames = Object.keys(models);
68
+
69
+ const header = `/**
70
+ * API types (ReactAppStarter)
71
+ *
72
+ * Per-model interfaces generated from the spec's model definitions.
73
+ * Safe to edit — extend these when your UI needs more than the spec
74
+ * describes (e.g. optimistic fields, denormalized joins).
75
+ */
76
+ `;
77
+
78
+ const interfaces = modelNames.map(name => {
79
+ const model = models[name] || {};
80
+ const attrs = model.attributes || {};
81
+ const attrKeys = Object.keys(attrs);
82
+
83
+ const fields = attrKeys.map(attrName => {
84
+ const attr = attrs[attrName];
85
+ const tsType = mapAttrType(attr);
86
+ const optional = isRequired(attr) ? '' : '?';
87
+ return ` ${attrName}${optional}: ${tsType};`;
88
+ }).join('\n');
89
+
90
+ return `export interface ${name} {
91
+ ${fields || ' [key: string]: unknown;'}
92
+ }`;
93
+ }).join('\n\n');
94
+
95
+ return header + '\n' + interfaces + '\n';
96
+ }
97
+
98
+ export default generate;
@@ -107,7 +107,7 @@ function buildNavEntries(models: string[]): string {
107
107
  </div>
108
108
  <button type="button" onClick={() => select('${m}', 'list')} className="${navButtonCls('list')}">List</button>
109
109
  <button type="button" onClick={() => select('${m}', 'dashboard')} className="${navButtonCls('dashboard')}">Dashboard</button>
110
- <button type="button" onClick={() => select('${m}', 'form')} className="${navButtonCls('form')}">New</button>
110
+ <button type="button" onClick={() => select('${m}', 'form')} className="${navButtonCls('form')}">Form</button>
111
111
  </div>`).join('\n');
112
112
  }
113
113
 
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Shared helper: extract belongsTo relationships from a model.
3
+ *
4
+ * Used by both the form-body composer (emits <select> dropdowns) and
5
+ * view-emitter (generates the import + hook wiring for those
6
+ * dropdowns). Keeping this in one place means form JSX and skeleton
7
+ * substitutions can't drift.
8
+ *
9
+ * Supports both shapes a parsed spec can use:
10
+ * - Convention string: "belongsTo User cascade"
11
+ * - Structured object: { type: "belongsTo", target: "User" }
12
+ */
13
+
14
+ import type { ModelSpec } from './view-emitter.js';
15
+
16
+ export interface BelongsToRel {
17
+ /** Relationship name — e.g. "owner", "assignee". */
18
+ name: string;
19
+ /** Target model name — e.g. "User". */
20
+ target: string;
21
+ }
22
+
23
+ export function extractBelongsToTargets(model: ModelSpec): BelongsToRel[] {
24
+ const rels = (model.relationships ?? {}) as Record<string, unknown>;
25
+ const out: BelongsToRel[] = [];
26
+
27
+ for (const [name, rawDef] of Object.entries(rels)) {
28
+ const parsed = parseBelongsTo(rawDef);
29
+ if (parsed) out.push({ name, target: parsed });
30
+ }
31
+ return out;
32
+ }
33
+
34
+ function parseBelongsTo(def: unknown): string | null {
35
+ // Convention string: "belongsTo User cascade" / "belongsTo User"
36
+ if (typeof def === 'string') {
37
+ const parts = def.trim().split(/\s+/);
38
+ if (parts[0] === 'belongsTo' && parts[1]) return parts[1];
39
+ return null;
40
+ }
41
+ // Structured: { type: "belongsTo", target: "User" } — support all
42
+ // of the field names the parsed spec can use ("target", "to",
43
+ // "model", "targetModel"). The normalizer upstream is inconsistent
44
+ // and normalizing it there is out of scope for this composer.
45
+ if (def && typeof def === 'object') {
46
+ const o = def as { type?: string; target?: string; to?: string; model?: string; targetModel?: string };
47
+ if (o.type === 'belongsTo') {
48
+ return o.target || o.to || o.model || o.targetModel || null;
49
+ }
50
+ }
51
+ return null;
52
+ }
53
+
54
+ /**
55
+ * Minimal English pluralizer, matching view-emitter's and
56
+ * use-api-hooks-starter-generator's convention. Centralizing it here
57
+ * keeps the generated hook names (useUsersQuery) aligned with what
58
+ * the emitter imports.
59
+ */
60
+ export function pluralize(s: string): string {
61
+ if (/[^aeiou]y$/i.test(s)) return s.slice(0, -1) + 'ies';
62
+ if (/(s|x|z|ch|sh)$/i.test(s)) return s + 'es';
63
+ return s + 's';
64
+ }
65
+
66
+ /**
67
+ * Build a map from FK column name → belongsTo relationship. Used by
68
+ * every view composer (list, detail, dashboard, form) to detect when
69
+ * an attribute is really a foreign key and should render as a resolved
70
+ * display name, not a raw UUID.
71
+ *
72
+ * Convention: a belongsTo relationship named `owner` pointing at `User`
73
+ * implies a column `ownerId: UUID` on the owning model. This function
74
+ * materializes that convention.
75
+ */
76
+ export function buildFKMap(model: ModelSpec): Map<string, BelongsToRel> {
77
+ const map = new Map<string, BelongsToRel>();
78
+ for (const rel of extractBelongsToTargets(model)) {
79
+ map.set(`${rel.name}Id`, rel);
80
+ }
81
+ return map;
82
+ }
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { METADATA_FIELDS } from '@specverse/runtime/views/core';
14
14
  import type { EmitContext, ModelSpec } from './view-emitter.js';
15
+ import { buildFKMap } from './belongs-to.js';
15
16
 
16
17
  const METADATA_FIELD_NAMES = new Set(METADATA_FIELDS);
17
18
 
@@ -19,6 +20,7 @@ export function composeDashboardBody(context: EmitContext): string {
19
20
  const modelName = context.model.name;
20
21
  const previewColumns = inferPreviewColumns(context.model);
21
22
  const enumFields = inferEnumFields(context.model);
23
+ const fkMap = buildFKMap(context.model);
22
24
 
23
25
  const lines: string[] = [];
24
26
 
@@ -55,9 +57,11 @@ export function composeDashboardBody(context: EmitContext): string {
55
57
  lines.push(' <thead>');
56
58
  lines.push(' <tr>');
57
59
  for (const col of previewColumns) {
60
+ const fk = fkMap.get(col);
61
+ const headerLabel = humanize(fk ? fk.name : col);
58
62
  lines.push(
59
63
  ` <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">` +
60
- humanize(col) +
64
+ headerLabel +
61
65
  `</th>`
62
66
  );
63
67
  }
@@ -71,9 +75,13 @@ export function composeDashboardBody(context: EmitContext): string {
71
75
  lines.push(' className="hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer"');
72
76
  lines.push(' >');
73
77
  for (const col of previewColumns) {
78
+ const fk = fkMap.get(col);
79
+ const expr = fk
80
+ ? `{resolveEntityDisplayName((item as any).${col}, ${fk.name}Options)}`
81
+ : `{String((item as any).${col} ?? '')}`;
74
82
  lines.push(
75
83
  ` <td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300">` +
76
- `{String((item as any).${col} ?? '')}</td>`
84
+ `${expr}</td>`
77
85
  );
78
86
  }
79
87
  lines.push(' </tr>');
@@ -161,9 +169,16 @@ function inferEnumFields(model: ModelSpec): EnumField[] {
161
169
 
162
170
  function inferPreviewColumns(model: ModelSpec): string[] {
163
171
  const attrs = model.attributes ?? {};
164
- return Object.keys(attrs)
165
- .filter(n => !METADATA_FIELD_NAMES.has(n))
166
- .slice(0, 4); // cap to keep the table readable
172
+ const attrCols = Object.keys(attrs).filter(n => !METADATA_FIELD_NAMES.has(n));
173
+
174
+ // Include belongsTo FK columns so the preview shows "who does this
175
+ // belong to" at a glance. Matches the list-view convention.
176
+ const fkCols = [...buildFKMap(model).keys()].filter(k => !attrCols.includes(k));
177
+
178
+ // FKs are always useful at-a-glance, so keep them all. Cap the
179
+ // combined set at 5 to keep the preview table readable without
180
+ // crowding out the "who" columns.
181
+ return [...fkCols, ...attrCols].slice(0, Math.max(5, fkCols.length + 1));
167
182
  }
168
183
 
169
184
  function humanize(name: string): string {
@@ -2,24 +2,18 @@
2
2
  * Detail-view body composer for ReactAppStarter
3
3
  *
4
4
  * Renders the interior of a detail view as a JSX-safe definition list:
5
- * business fields first (prominent), metadata fields second (muted).
5
+ * business fields first (prominent), belongsTo relationships resolved
6
+ * to display names in a dedicated section, metadata fields last (muted).
6
7
  * The outer card + actions live in `skeletons/detail.tsx.template`.
7
8
  *
8
- * Unlike list view, the interior here is mostly label→value pairs, not
9
- * a table. We use `<dl>` / `<dt>` / `<dd>` directly rather than routing
10
- * through a specific atomic component — the field-list shape isn't one
11
- * of the adapter's atomic components, and forcing it through a `card`
12
- * adapter would add structure without clarifying anything in the
13
- * generated code.
14
- *
15
- * BelongsTo relationships: today we emit the raw FK id. A future pass
16
- * will resolve FK → entity display name (matches runtime's
17
- * getEntityDisplayName). Marked with a TODO comment in the output so
18
- * users inspecting the generated file see the hook.
9
+ * belongsTo FK columns are resolved via `resolveEntityDisplayName`
10
+ * using option arrays (${relName}Options) wired in by view-emitter's
11
+ * RELATED_HOOKS substitution.
19
12
  */
20
13
 
21
14
  import { METADATA_FIELDS } from '@specverse/runtime/views/core';
22
15
  import type { EmitContext, ModelSpec } from './view-emitter.js';
16
+ import { extractBelongsToTargets, type BelongsToRel } from './belongs-to.js';
23
17
 
24
18
  /**
25
19
  * Metadata attribute names rendered in a muted style. Sourced from
@@ -29,8 +23,12 @@ import type { EmitContext, ModelSpec } from './view-emitter.js';
29
23
  const METADATA_FIELD_NAMES = new Set(METADATA_FIELDS);
30
24
 
31
25
  export function composeDetailBody(context: EmitContext): string {
32
- const { business, metadata } = partitionFields(context.model);
33
- const belongsTo = extractBelongsTo(context.model);
26
+ // FK columns are excluded from business so we don't emit the raw
27
+ // UUID next to the resolved name. They surface in the belongsTo
28
+ // section instead, rendered via resolveEntityDisplayName.
29
+ const belongsTo = extractBelongsToTargets(context.model);
30
+ const fkExcludes = new Set(belongsTo.map(r => `${r.name}Id`));
31
+ const { business, metadata } = partitionFields(context.model, fkExcludes);
34
32
 
35
33
  const lines: string[] = [];
36
34
  lines.push('<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-900">');
@@ -41,18 +39,15 @@ export function composeDetailBody(context: EmitContext): string {
41
39
  lines.push(...renderField(field, { muted: false }));
42
40
  }
43
41
  lines.push(' </dl>');
44
- } else {
42
+ } else if (belongsTo.length === 0) {
45
43
  lines.push(' <p className="text-sm text-gray-400">No business fields defined for this model.</p>');
46
44
  }
47
45
 
48
46
  if (belongsTo.length > 0) {
49
47
  lines.push('');
50
- lines.push(' {/* TODO: resolve FK ids → related entity display names.');
51
- lines.push(' See @specverse/runtime/views/core/entity-display for the');
52
- lines.push(' canonical resolver, or load the related record in a hook. */}');
53
48
  lines.push(' <dl className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 border-t border-gray-200 dark:border-gray-700 pt-4">');
54
49
  for (const rel of belongsTo) {
55
- lines.push(...renderField({ name: `${rel}Id`, label: humanize(rel) }, { muted: false }));
50
+ lines.push(...renderRelationshipField(rel));
56
51
  }
57
52
  lines.push(' </dl>');
58
53
  }
@@ -76,11 +71,15 @@ interface Field {
76
71
  label?: string;
77
72
  }
78
73
 
79
- function partitionFields(model: ModelSpec): { business: Field[]; metadata: Field[] } {
74
+ function partitionFields(
75
+ model: ModelSpec,
76
+ exclude: Set<string> = new Set(),
77
+ ): { business: Field[]; metadata: Field[] } {
80
78
  const attrs = Object.keys(model.attributes ?? {});
81
79
  const business: Field[] = [];
82
80
  const metadata: Field[] = [];
83
81
  for (const name of attrs) {
82
+ if (exclude.has(name)) continue;
84
83
  const field = { name, label: humanize(name) };
85
84
  if (METADATA_FIELD_NAMES.has(name)) {
86
85
  metadata.push(field);
@@ -92,20 +91,21 @@ function partitionFields(model: ModelSpec): { business: Field[]; metadata: Field
92
91
  }
93
92
 
94
93
  /**
95
- * Extract belongsTo relationship names from the model. Returns e.g.
96
- * `['author']` for a Post that `belongsTo Author`. The skeleton's
97
- * item already has `authorId` as a scalar field — we surface it
98
- * explicitly in its own section so the generated UI separates "this
99
- * entity's data" from "related entities."
94
+ * Render a belongsTo row as a label + resolved display name. The
95
+ * options array (${relName}Options) is populated by the hook call
96
+ * wired into the detail skeleton via view-emitter's RELATED_HOOKS.
100
97
  */
101
- function extractBelongsTo(model: ModelSpec): string[] {
102
- const rels = model.relationships ?? {};
103
- const out: string[] = [];
104
- for (const [name, def] of Object.entries(rels)) {
105
- const d = def as { type?: string };
106
- if (d?.type === 'belongsTo') out.push(name);
107
- }
108
- return out;
98
+ function renderRelationshipField(rel: BelongsToRel): string[] {
99
+ const label = humanize(rel.name);
100
+ const fkName = `${rel.name}Id`;
101
+ const optionsVar = `${rel.name}Options`;
102
+ return [
103
+ ' <div>',
104
+ ` <dt className="text-sm font-medium text-gray-500 dark:text-gray-400">${label}</dt>`,
105
+ ` <dd className="mt-1 text-sm text-gray-900 dark:text-gray-100 break-words">` +
106
+ `{resolveEntityDisplayName((item as any).${fkName}, ${optionsVar})}</dd>`,
107
+ ' </div>',
108
+ ];
109
109
  }
110
110
 
111
111
  function renderField(field: Field, opts: { muted: boolean }): string[] {
@@ -10,15 +10,16 @@
10
10
  * Auto-generated fields (id, timestamps, `auto=...` markers) are
11
11
  * omitted — the backend assigns them.
12
12
  *
13
- * Relationship fields (belongsTo) are emitted as plain-text inputs
14
- * for the FK column with a TODO comment. Wiring them to a proper
15
- * dropdown requires a hook that loads the related list at form render
16
- * time; deferred to a later pass. The inline TODO is the hook for
17
- * users who want to upgrade.
13
+ * Relationship fields (belongsTo) are emitted as <select> dropdowns
14
+ * backed by the related model's list query. The corresponding hook
15
+ * calls and imports are wired by view-emitter via the RELATED_HOOKS /
16
+ * RELATED_IMPORTS substitutions this composer only emits the JSX
17
+ * that consumes the `${relName}Options` variable.
18
18
  */
19
19
 
20
20
  import { METADATA_FIELDS } from '@specverse/runtime/views/core';
21
21
  import type { EmitContext, ModelSpec } from './view-emitter.js';
22
+ import { extractBelongsToTargets, type BelongsToRel } from './belongs-to.js';
22
23
 
23
24
  /**
24
25
  * Attribute names the backend always generates — never render as
@@ -32,11 +33,12 @@ const AUTO_GENERATED_FIELD_NAMES = new Set(METADATA_FIELDS);
32
33
  * Compose the form body as JSX-safe source. Dropped at `{{BODY}}`.
33
34
  */
34
35
  export function composeFormBody(context: EmitContext): string {
35
- const belongsTo = extractBelongsTo(context.model);
36
+ const belongsTo = extractBelongsToTargets(context.model);
36
37
  // FK columns shadowed by a belongsTo relationship are emitted in the
37
- // belongsTo section with a nicer label ("Author" instead of
38
- // "Author Id") skip them in the main loop so we don't render twice.
39
- const shadowedFKs = new Set(belongsTo.map(rel => `${rel}Id`));
38
+ // belongsTo section as <select>s skip them in the main loop so we
39
+ // don't render both a plain-text input and a dropdown for the same
40
+ // column.
41
+ const shadowedFKs = new Set(belongsTo.map(rel => `${rel.name}Id`));
40
42
  const fields = selectFormFields(context.model, shadowedFKs);
41
43
 
42
44
  const lines: string[] = [];
@@ -52,24 +54,43 @@ export function composeFormBody(context: EmitContext): string {
52
54
  lines.push(...renderInput(field));
53
55
  }
54
56
 
55
- if (belongsTo.length > 0) {
56
- lines.push('');
57
- lines.push(' {/* TODO: swap these text inputs for <select>s that load the related');
58
- lines.push(' entities. See useEntitiesQuery(TargetModel) in the runtime hooks. */}');
59
- for (const rel of belongsTo) {
60
- lines.push(...renderInput({
61
- name: `${rel}Id`,
62
- label: humanize(rel),
63
- type: 'String',
64
- required: true,
65
- }));
66
- }
57
+ for (const rel of belongsTo) {
58
+ lines.push(...renderRelationshipSelect(rel));
67
59
  }
68
60
 
69
61
  lines.push('</div>');
70
62
  return lines.join('\n');
71
63
  }
72
64
 
65
+ /**
66
+ * Emit a <select> dropdown for a belongsTo relationship. The options
67
+ * array (`${relName}Options`) is populated by the hook call wired in
68
+ * via view-emitter's RELATED_HOOKS substitution. Labels use
69
+ * `getEntityDisplayName` so users see human names instead of UUIDs.
70
+ */
71
+ function renderRelationshipSelect(rel: BelongsToRel): string[] {
72
+ const fkName = `${rel.name}Id`;
73
+ const varName = `${rel.name}Options`;
74
+ const label = humanize(rel.name);
75
+
76
+ return [
77
+ ' <div>',
78
+ ` <label className="${LABEL_CLS}" htmlFor="${fkName}">${label} *</label>`,
79
+ ` <select`,
80
+ ` id="${fkName}"`,
81
+ ` className="${INPUT_CLS}"`,
82
+ ` value={String((formData as any).${fkName} ?? '')}`,
83
+ ` onChange={e => handleChange('${fkName}', e.target.value)} required`,
84
+ ` >`,
85
+ ` <option value="">— choose —</option>`,
86
+ ` {${varName}.map((opt: any) => (`,
87
+ ` <option key={opt.id} value={opt.id}>{getEntityDisplayName(opt)}</option>`,
88
+ ` ))}`,
89
+ ` </select>`,
90
+ ' </div>',
91
+ ];
92
+ }
93
+
73
94
  // ──────────────────────────────────────────────────────────────────────
74
95
  // Field selection
75
96
  // ──────────────────────────────────────────────────────────────────────
@@ -90,6 +111,8 @@ function selectFormFields(
90
111
  ): FieldInfo[] {
91
112
  const out: FieldInfo[] = [];
92
113
  const attrs = model.attributes ?? {};
114
+ const lifecycleStates = extractLifecycleStates(model);
115
+
93
116
  for (const [name, rawDef] of Object.entries(attrs)) {
94
117
  if (AUTO_GENERATED_FIELD_NAMES.has(name)) continue;
95
118
  if (skip.has(name)) continue;
@@ -102,7 +125,14 @@ function selectFormFields(
102
125
  // they're present.
103
126
  const type = (def?.type ?? parseTypeFromConvention(def)) ?? 'String';
104
127
  const required = def?.required === true || hasConventionFlag(def, 'required');
105
- const values = def?.values as string[] | undefined;
128
+ const declaredValues = def?.values as string[] | undefined;
129
+ // Lifecycle-backed attributes: if the model declares a lifecycle
130
+ // with the same name as this attribute, the attribute's valid
131
+ // values are the lifecycle states. Matches the FK rule's spirit —
132
+ // any attribute whose legal values are declared elsewhere in the
133
+ // spec should render as a <select>, not free text.
134
+ const lifecycleValues = lifecycleStates.get(name);
135
+ const values = declaredValues ?? lifecycleValues;
106
136
  const maxLength = def?.maxLength ?? def?.max as number | undefined;
107
137
  const isLongString =
108
138
  typeof maxLength === 'number' && maxLength > 100;
@@ -119,6 +149,58 @@ function selectFormFields(
119
149
  return out;
120
150
  }
121
151
 
152
+ /**
153
+ * Map from attribute name → list of lifecycle states, extracted from
154
+ * the model's `lifecycles` section. Supports both the convention-string
155
+ * shape (`flow: "a -> b -> c"`) and structured (`states: ["a", ...]`).
156
+ *
157
+ * Example:
158
+ * lifecycles:
159
+ * status:
160
+ * flow: "planning -> active -> completed -> archived"
161
+ * returns { status → ['planning', 'active', 'completed', 'archived'] }
162
+ */
163
+ function extractLifecycleStates(model: ModelSpec): Map<string, string[]> {
164
+ const out = new Map<string, string[]>();
165
+ const lifecycles = (model as ModelSpec & { lifecycles?: Record<string, unknown> }).lifecycles ?? {};
166
+
167
+ for (const [name, rawDef] of Object.entries(lifecycles)) {
168
+ if (!rawDef || typeof rawDef !== 'object') continue;
169
+ // The parser normalizes lifecycles into a rich structured form:
170
+ // { flow: "a -> b -> c",
171
+ // states: [{ name: "a" }, { name: "b" }, { name: "c" }],
172
+ // transitions: [...],
173
+ // initialState: "a" }
174
+ // Also support the pre-normalization shape (states as plain strings,
175
+ // or just a flow string) so the composer works on raw specs too.
176
+ const def = rawDef as {
177
+ flow?: string;
178
+ states?: Array<string | { name?: string; id?: string }>;
179
+ };
180
+
181
+ if (Array.isArray(def.states) && def.states.length > 0) {
182
+ const names = def.states
183
+ .map(s => (typeof s === 'string' ? s : s?.name ?? s?.id ?? ''))
184
+ .filter(Boolean);
185
+ if (names.length > 0) {
186
+ out.set(name, names);
187
+ continue;
188
+ }
189
+ }
190
+
191
+ if (typeof def.flow === 'string') {
192
+ // Split on -> with optional whitespace. Also accept commas as a
193
+ // fallback separator so a state list like "a, b, c" still works.
194
+ const states = def.flow
195
+ .split(/\s*(?:->|,)\s*/)
196
+ .map(s => s.trim())
197
+ .filter(Boolean);
198
+ if (states.length > 0) out.set(name, states);
199
+ }
200
+ }
201
+ return out;
202
+ }
203
+
122
204
  /** Structured attribute object from the parsed spec. */
123
205
  interface AttributeShape {
124
206
  type?: string;
@@ -152,14 +234,9 @@ function normalizeType(type: string): string {
152
234
  return type.replace(/[^A-Za-z].*$/, '');
153
235
  }
154
236
 
155
- function extractBelongsTo(model: ModelSpec): string[] {
156
- const rels = model.relationships ?? {};
157
- const out: string[] = [];
158
- for (const [name, def] of Object.entries(rels)) {
159
- if ((def as { type?: string })?.type === 'belongsTo') out.push(name);
160
- }
161
- return out;
162
- }
237
+ // (`extractBelongsTo` was removed — the shared helper in
238
+ // `./belongs-to.ts` returns both the relationship name AND the target
239
+ // model so view-emitter can wire the hook call for each dropdown.)
163
240
 
164
241
  // ──────────────────────────────────────────────────────────────────────
165
242
  // Input rendering
@@ -46,14 +46,20 @@ export function getEntityDisplayName(entity: Record<string, unknown> | null | un
46
46
  /**
47
47
  * Map an entity id to the display name of the record with that id in
48
48
  * a provided list. Handy for resolving belongsTo FK columns in
49
- * list / detail views.
49
+ * list / detail / dashboard views.
50
+ *
51
+ * Typed as \`readonly unknown[]\` so callers can pass arrays of
52
+ * specific model types (e.g. \`User[]\`) without needing to cast; the
53
+ * runtime check on \`.id\` narrows safely.
50
54
  */
51
55
  export function resolveEntityDisplayName(
52
56
  id: unknown,
53
- records: ReadonlyArray<Record<string, unknown>>
57
+ records: readonly unknown[]
54
58
  ): string {
55
59
  if (id == null) return '';
56
- const match = records.find(r => r.id === id);
60
+ const match = records.find((r): r is Record<string, unknown> =>
61
+ typeof r === 'object' && r !== null && (r as { id?: unknown }).id === id
62
+ );
57
63
  return match ? getEntityDisplayName(match) : String(id);
58
64
  }
59
65
  `;