@specverse/engines 4.2.2 → 4.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/inference/core/specly-converter.d.ts.map +1 -1
  2. package/dist/inference/core/specly-converter.js +43 -22
  3. package/dist/inference/core/specly-converter.js.map +1 -1
  4. package/dist/inference/index.d.ts.map +1 -1
  5. package/dist/inference/index.js +29 -0
  6. package/dist/inference/index.js.map +1 -1
  7. package/dist/inference/ui-contracts/rules/action-buttons-present.d.ts +8 -5
  8. package/dist/inference/ui-contracts/rules/action-buttons-present.d.ts.map +1 -1
  9. package/dist/inference/ui-contracts/rules/action-buttons-present.js +85 -19
  10. package/dist/inference/ui-contracts/rules/action-buttons-present.js.map +1 -1
  11. package/dist/inference/ui-contracts/test-case-types.d.ts +3 -0
  12. package/dist/inference/ui-contracts/test-case-types.d.ts.map +1 -1
  13. package/dist/inference/ui-contracts/translator.d.ts.map +1 -1
  14. package/dist/inference/ui-contracts/translator.js +4 -0
  15. package/dist/inference/ui-contracts/translator.js.map +1 -1
  16. package/dist/libs/instance-factories/applications/templates/react/index-html-generator.js +2 -2
  17. package/dist/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.js +291 -59
  18. package/dist/libs/instance-factories/applications/templates/react-starter/belongs-to.js +37 -0
  19. package/dist/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.js +9 -124
  20. package/dist/libs/instance-factories/applications/templates/react-starter/detail-body-composer.js +9 -75
  21. package/dist/libs/instance-factories/applications/templates/react-starter/emit-jsx-source.js +129 -0
  22. package/dist/libs/instance-factories/applications/templates/react-starter/form-body-composer.js +9 -212
  23. package/dist/libs/instance-factories/applications/templates/react-starter/helpers-emitter.js +260 -5
  24. package/dist/libs/instance-factories/applications/templates/react-starter/list-body-composer.js +8 -50
  25. package/dist/libs/instance-factories/applications/templates/react-starter/operation-emitters.js +470 -0
  26. package/dist/libs/instance-factories/applications/templates/react-starter/operation-view-generator.js +136 -0
  27. package/dist/libs/instance-factories/applications/templates/react-starter/package-json-generator.js +2 -1
  28. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +54 -61
  29. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +115 -27
  30. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +17 -9
  31. package/dist/libs/instance-factories/applications/templates/react-starter/use-api-hooks-starter-generator.js +49 -10
  32. package/dist/libs/instance-factories/applications/templates/react-starter/view-emitter.js +223 -10
  33. package/dist/libs/instance-factories/applications/templates/react-starter/views-generator.js +14 -1
  34. package/dist/libs/instance-factories/controllers/templates/fastify/routes-generator.js +13 -1
  35. package/dist/libs/instance-factories/controllers/templates/fastify/server-generator.js +18 -0
  36. package/libs/instance-factories/applications/templates/react/index-html-generator.ts +2 -2
  37. package/libs/instance-factories/applications/templates/react-starter/__tests__/dashboard-body-composer.test.ts +3 -1
  38. package/libs/instance-factories/applications/templates/react-starter/__tests__/detail-body-composer.test.ts +24 -25
  39. package/libs/instance-factories/applications/templates/react-starter/__tests__/list-body-composer.test.ts +3 -3
  40. package/libs/instance-factories/applications/templates/react-starter/__tests__/orchestrator.test.ts +2 -0
  41. package/libs/instance-factories/applications/templates/react-starter/__tests__/parity-p3-rendered-output.test.ts +1 -1
  42. package/libs/instance-factories/applications/templates/react-starter/__tests__/starter-generators.test.ts +5 -4
  43. package/libs/instance-factories/applications/templates/react-starter/__tests__/views-generator.test.ts +11 -4
  44. package/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.ts +377 -71
  45. package/libs/instance-factories/applications/templates/react-starter/belongs-to.ts +66 -0
  46. package/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.ts +15 -182
  47. package/libs/instance-factories/applications/templates/react-starter/detail-body-composer.ts +16 -128
  48. package/libs/instance-factories/applications/templates/react-starter/emit-jsx-source.ts +233 -0
  49. package/libs/instance-factories/applications/templates/react-starter/form-body-composer.ts +16 -376
  50. package/libs/instance-factories/applications/templates/react-starter/helpers-emitter.ts +282 -4
  51. package/libs/instance-factories/applications/templates/react-starter/list-body-composer.ts +26 -135
  52. package/libs/instance-factories/applications/templates/react-starter/operation-emitters.ts +497 -0
  53. package/libs/instance-factories/applications/templates/react-starter/operation-view-generator.ts +209 -0
  54. package/libs/instance-factories/applications/templates/react-starter/package-json-generator.ts +1 -0
  55. package/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +54 -61
  56. package/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +115 -27
  57. package/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +17 -9
  58. package/libs/instance-factories/applications/templates/react-starter/use-api-hooks-starter-generator.ts +71 -10
  59. package/libs/instance-factories/applications/templates/react-starter/view-emitter.ts +359 -18
  60. package/libs/instance-factories/applications/templates/react-starter/views-generator.ts +28 -1
  61. package/libs/instance-factories/controllers/templates/fastify/routes-generator.ts +13 -1
  62. package/libs/instance-factories/controllers/templates/fastify/server-generator.ts +18 -0
  63. package/package.json +2 -2
@@ -30,6 +30,29 @@ export interface UseApiHooksStarterContext {
30
30
  manifest?: unknown;
31
31
  }
32
32
 
33
+ /**
34
+ * Extract lifecycle metadata from a model for the Evolve mutation.
35
+ * Returns an array of { name, states } entries. Both normalized
36
+ * (`states: [{name}]`) and raw (`flow: "a -> b -> c"`) shapes handled.
37
+ */
38
+ function extractLifecycles(model: any): Array<{ name: string; states: string[] }> {
39
+ const out: Array<{ name: string; states: string[] }> = [];
40
+ const lifecycles = (model?.lifecycles ?? {}) as Record<string, any>;
41
+ for (const [name, def] of Object.entries(lifecycles)) {
42
+ if (!def || typeof def !== 'object') continue;
43
+ let states: string[] = [];
44
+ if (Array.isArray(def.states)) {
45
+ states = def.states
46
+ .map((s: any) => typeof s === 'string' ? s : s?.name ?? s?.id ?? '')
47
+ .filter(Boolean);
48
+ } else if (typeof def.flow === 'string') {
49
+ states = def.flow.split(/\s*(?:->|,)\s*/).map((s: string) => s.trim()).filter(Boolean);
50
+ }
51
+ if (states.length > 0) out.push({ name, states });
52
+ }
53
+ return out;
54
+ }
55
+
33
56
  export async function generate(context: UseApiHooksStarterContext): Promise<string> {
34
57
  const models = Object.keys(context.spec.models ?? {});
35
58
 
@@ -48,10 +71,13 @@ ${models.map(m => `import type { ${m} } from '../types/api';`).join('\n')}
48
71
  const API_BASE = import.meta.env.VITE_API_BASE_URL || '';
49
72
 
50
73
  async function fetchJSON<T = unknown>(url: string, init?: RequestInit): Promise<T> {
51
- const res = await fetch(url, {
52
- headers: { 'Content-Type': 'application/json' },
53
- ...init,
54
- });
74
+ // Only declare Content-Type: application/json when there's actually a
75
+ // body. Fastify's default JSON parser rejects empty-body requests that
76
+ // claim JSON (FST_ERR_CTP_EMPTY_JSON_BODY) — so DELETE / GET without
77
+ // bodies must NOT send the header.
78
+ const headers: Record<string, string> = {};
79
+ if (init?.body != null) headers['Content-Type'] = 'application/json';
80
+ const res = await fetch(url, { ...init, headers: { ...headers, ...init?.headers } });
55
81
  if (!res.ok) {
56
82
  throw new Error(\`\${res.status} \${res.statusText} — \${url}\`);
57
83
  }
@@ -60,18 +86,53 @@ async function fetchJSON<T = unknown>(url: string, init?: RequestInit): Promise<
60
86
  }
61
87
  `;
62
88
 
63
- const hookBlocks = models.map(m => generateModelHooks(m)).join('\n\n');
89
+ const hookBlocks = models
90
+ .map(m => generateModelHooks(m, context.spec.models?.[m]))
91
+ .join('\n\n');
64
92
 
65
93
  return importsAndTypes + '\n' + hookBlocks + '\n';
66
94
  }
67
95
 
68
- function generateModelHooks(model: string): string {
96
+ function generateModelHooks(model: string, modelDef: any): string {
69
97
  const plural = pluralize(model);
70
98
  const resource = plural.toLowerCase();
71
- const modelLower = model.toLowerCase();
99
+ const lifecycles = extractLifecycles(modelDef);
100
+ const hasLifecycle = lifecycles.length > 0;
101
+
102
+ // Lifecycle metadata for the Evolve tab. Stored as a const so form
103
+ // skeletons can read state lists + primary lifecycle name without
104
+ // re-parsing the spec. One entry per declared lifecycle attribute.
105
+ const lifecycleBlock = hasLifecycle
106
+ ? `
107
+ export const ${model.toUpperCase()}_LIFECYCLES = ${JSON.stringify(
108
+ Object.fromEntries(lifecycles.map(l => [l.name, { states: l.states }])),
109
+ null,
110
+ 2,
111
+ )} as const;
112
+ `
113
+ : '';
114
+
115
+ // Evolve mutation — only emitted for models with at least one
116
+ // lifecycle. Body shape matches the generated backend's evolve
117
+ // handler: { toState, lifecycleName? } → update that column.
118
+ const evolveHook = hasLifecycle
119
+ ? `
120
+ export function useEvolve${model}Mutation() {
121
+ const qc = useQueryClient();
122
+ return useMutation({
123
+ mutationFn: ({ id, toState, lifecycleName }: { id: string | number; toState: string; lifecycleName?: string }) =>
124
+ fetchJSON<${model}>(\`\${API_BASE}/api/${resource}/\${id}/evolve\`, {
125
+ method: 'PATCH',
126
+ body: JSON.stringify({ toState, lifecycleName }),
127
+ }),
128
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['${resource}'] }),
129
+ });
130
+ }
131
+ `
132
+ : '';
72
133
 
73
134
  return `// ─── ${model} ───────────────────────────────────────────────────
74
-
135
+ ${lifecycleBlock}
75
136
  export function use${plural}Query() {
76
137
  return useQuery({
77
138
  queryKey: ['${resource}'],
@@ -104,7 +165,7 @@ export function useUpdate${model}Mutation() {
104
165
  return useMutation({
105
166
  mutationFn: ({ id, data }: { id: string | number; data: Partial<${model}> }) =>
106
167
  fetchJSON<${model}>(\`\${API_BASE}/api/${resource}/\${id}\`, {
107
- method: 'PATCH',
168
+ method: 'PUT',
108
169
  body: JSON.stringify(data),
109
170
  }),
110
171
  onSuccess: () => qc.invalidateQueries({ queryKey: ['${resource}'] }),
@@ -118,7 +179,7 @@ export function useDelete${model}Mutation() {
118
179
  fetchJSON<void>(\`\${API_BASE}/api/${resource}/\${id}\`, { method: 'DELETE' }),
119
180
  onSuccess: () => qc.invalidateQueries({ queryKey: ['${resource}'] }),
120
181
  });
121
- }`;
182
+ }${evolveHook}`;
122
183
  }
123
184
 
124
185
  export default generate;
@@ -16,7 +16,9 @@
16
16
  import { readFileSync } from 'fs';
17
17
  import { join, dirname } from 'path';
18
18
  import { fileURLToPath } from 'url';
19
- import { extractBelongsToTargets, pluralize as pluralizeShared } from './belongs-to.js';
19
+ import { walkPatternSection } from '@specverse/runtime/views/core';
20
+ import { extractBelongsToTargets, extractHasManyTargets, pluralize as pluralizeShared } from './belongs-to.js';
21
+ import { emitJsxSource } from './emit-jsx-source.js';
20
22
 
21
23
  /**
22
24
  * The minimal shape of a view spec this emitter needs. Matches the
@@ -115,23 +117,57 @@ interface Substitutions {
115
117
  SINGULAR_LOWER: string;
116
118
  BODY: string;
117
119
  /**
118
- * For form views: extra import lines that pull in the hooks and
119
- * display-name helper needed to render belongsTo <select>s. Empty
120
- * string for views / models with no belongsTo relationships.
120
+ * Extra import lines that pull in the hooks and display-name
121
+ * helpers this view needs for related models (belongsTo dropdowns,
122
+ * belongsTo FK resolution, hasMany lists in detail views). Empty
123
+ * string when there are no related models to wire.
121
124
  */
122
125
  RELATED_IMPORTS: string;
123
126
  /**
124
- * For form views: extra hook calls inside the component body —
125
- * one per belongsTo target that populate the `${relName}Options`
126
- * array each <select> iterates. Empty string when there are none.
127
+ * Extra hook calls inside the component body — one per related
128
+ * target. belongsTo \`${relName}Options\`, hasMany \`${relName}All\`.
129
+ * Empty string when none.
127
130
  */
128
131
  RELATED_HOOKS: string;
132
+ /**
133
+ * Detail-view tabbed-related-lists state. A \`useState\` line
134
+ * declaring \`relatedTab\` + \`setRelatedTab\` when the detail has
135
+ * more than one hasMany. Empty otherwise.
136
+ */
137
+ RELATED_TAB_STATE: string;
138
+ /**
139
+ * Detail-view tabbed-related-lists import line. \`import { useState }
140
+ * from 'react';\` when tabs are emitted, empty otherwise (strict TS
141
+ * rejects unused imports).
142
+ */
143
+ RELATED_TAB_IMPORT: string;
144
+ /**
145
+ * Form-view lifecycle substitutions. Populated only when the model
146
+ * has >=1 lifecycle. Drive the Edit/Evolve tab block in form.tsx.template.
147
+ */
148
+ LIFECYCLE_IMPORTS: string;
149
+ LIFECYCLE_HOOK: string;
150
+ LIFECYCLE_STATE: string;
151
+ LIFECYCLE_HANDLER: string;
152
+ LIFECYCLE_PANEL: string;
153
+ HAS_LIFECYCLES: string;
154
+ /**
155
+ * Form-view body split into two slots so the Create/Update button
156
+ * row can sit BETWEEN the fields and the existing-entities list
157
+ * (matching app-demo's layout). FIELDS_BODY goes inside <form>,
158
+ * EXISTING_LIST_BODY after the button row, outside <form>. Empty
159
+ * for non-form views.
160
+ */
161
+ FIELDS_BODY: string;
162
+ EXISTING_LIST_BODY: string;
129
163
  }
130
164
 
131
165
  function buildSubstitutions(context: EmitContext, body: string): Substitutions {
132
166
  const modelName = context.model.name;
133
167
  const pluralModel = pluralize(modelName);
134
168
  const { imports, hooks } = buildBelongsToWiring(context);
169
+ const lifecycle = buildLifecycleSubstitutions(context);
170
+ const { fieldsBody, existingListBody } = buildFormSplitBodies(context);
135
171
  return {
136
172
  MODEL_NAME: modelName,
137
173
  PLURAL_MODEL: pluralModel,
@@ -140,9 +176,204 @@ function buildSubstitutions(context: EmitContext, body: string): Substitutions {
140
176
  BODY: body,
141
177
  RELATED_IMPORTS: imports,
142
178
  RELATED_HOOKS: hooks,
179
+ RELATED_TAB_STATE: buildRelatedTabState(context),
180
+ RELATED_TAB_IMPORT: buildRelatedTabImport(context),
181
+ FIELDS_BODY: fieldsBody,
182
+ EXISTING_LIST_BODY: existingListBody,
183
+ ...lifecycle,
184
+ };
185
+ }
186
+
187
+ /**
188
+ * Walk the form pattern's `fields` and `existing-entities-list`
189
+ * sections separately so the skeleton can place them in different
190
+ * DOM locations (fields inside <form>, existing-list outside +
191
+ * below the button row). Empty for non-form views.
192
+ */
193
+ function buildFormSplitBodies(context: EmitContext): { fieldsBody: string; existingListBody: string } {
194
+ if (context.view.type.toLowerCase() !== 'form') {
195
+ return { fieldsBody: '', existingListBody: '' };
196
+ }
197
+ const walkCtx = {
198
+ model: context.model,
199
+ modelSchemas: context.modelSchemas as any,
200
+ view: context.view as any,
201
+ };
202
+ const imports = new Set<string>();
203
+ const fieldsNodes = walkPatternSection('form-view', 'fields', walkCtx);
204
+ const listNodes = walkPatternSection('form-view', 'existing-entities-list', walkCtx);
205
+ return {
206
+ fieldsBody: emitJsxSource(fieldsNodes, { imports, indent: 10 }),
207
+ existingListBody: emitJsxSource(listNodes, { imports, indent: 6 }),
208
+ };
209
+ }
210
+
211
+ /**
212
+ * Compute the form-view lifecycle block. Empty across the board for
213
+ * non-form views or models without declared lifecycles — strict TS
214
+ * rejects unused imports, so all placeholders must be conditional.
215
+ *
216
+ * The generated Evolve tab: a state picker + Save button that invokes
217
+ * \`useEvolve{Model}Mutation\` with { id, toState, lifecycleName }.
218
+ * Uses the primary (first-declared) lifecycle to drive the picker —
219
+ * multi-lifecycle support (a picker-per-lifecycle) is a later extension.
220
+ */
221
+ function buildLifecycleSubstitutions(context: EmitContext): {
222
+ LIFECYCLE_IMPORTS: string;
223
+ LIFECYCLE_HOOK: string;
224
+ LIFECYCLE_STATE: string;
225
+ LIFECYCLE_HANDLER: string;
226
+ LIFECYCLE_PANEL: string;
227
+ HAS_LIFECYCLES: string;
228
+ } {
229
+ const isForm = context.view.type.toLowerCase() === 'form';
230
+ const lifecycles = extractLifecycles(context.model);
231
+ if (!isForm || lifecycles.length === 0) {
232
+ return {
233
+ LIFECYCLE_IMPORTS: '',
234
+ LIFECYCLE_HOOK: '',
235
+ LIFECYCLE_STATE: '',
236
+ LIFECYCLE_HANDLER: '',
237
+ LIFECYCLE_PANEL: '',
238
+ HAS_LIFECYCLES: 'false',
239
+ };
240
+ }
241
+
242
+ const modelName = context.model.name;
243
+ const primary = lifecycles[0];
244
+ const lifecyclesConst = `${modelName.toUpperCase()}_LIFECYCLES`;
245
+
246
+ return {
247
+ LIFECYCLE_IMPORTS: ` useEvolve${modelName}Mutation,\n ${lifecyclesConst},`,
248
+ LIFECYCLE_HOOK: ` const evolveItem = useEvolve${modelName}Mutation();`,
249
+ LIFECYCLE_STATE: ` const [targetState, setTargetState] = useState<string>('');`,
250
+ LIFECYCLE_HANDLER: ` const handleEvolveSubmit = async (event: React.FormEvent) => {
251
+ event.preventDefault();
252
+ if (!targetState) return;
253
+ try {
254
+ const result = await evolveItem.mutateAsync({
255
+ id: entityId as any,
256
+ toState: targetState,
257
+ lifecycleName: '${primary.name}',
258
+ });
259
+ onSuccess?.(result as ${modelName});
260
+ } catch {
261
+ // Evolve errors are surfaced via evolveItem.isError.
262
+ }
263
+ };`,
264
+ LIFECYCLE_PANEL: buildLifecyclePanel(modelName, primary, lifecyclesConst),
265
+ HAS_LIFECYCLES: 'true',
143
266
  };
144
267
  }
145
268
 
269
+ function buildLifecyclePanel(
270
+ modelName: string,
271
+ primary: { name: string; states: string[] },
272
+ lifecyclesConst: string,
273
+ ): string {
274
+ return ` {showTabs && activeTab === 'evolve' && (
275
+ <form onSubmit={handleEvolveSubmit} className="space-y-6">
276
+ <div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-900">
277
+ <dl className="grid grid-cols-1 gap-4 sm:grid-cols-2">
278
+ <div>
279
+ <dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Current ${humanize(primary.name)}</dt>
280
+ <dd className="mt-1">
281
+ <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
282
+ {String((existing as any)?.${primary.name} ?? '')}
283
+ </span>
284
+ </dd>
285
+ </div>
286
+ <div>
287
+ <label htmlFor="evolve-target" className="block text-sm font-medium text-gray-500 dark:text-gray-400">Next ${humanize(primary.name)}</label>
288
+ <select
289
+ id="evolve-target"
290
+ value={targetState}
291
+ onChange={e => setTargetState(e.target.value)}
292
+ className="mt-1 w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
293
+ >
294
+ <option value="">— choose —</option>
295
+ {${lifecyclesConst}.${primary.name}.states.map(s => (
296
+ <option key={s} value={s}>{s}</option>
297
+ ))}
298
+ </select>
299
+ </div>
300
+ </dl>
301
+ </div>
302
+
303
+ <div>
304
+ <button
305
+ type="submit"
306
+ disabled={evolveItem.isPending || !targetState}
307
+ className="rounded bg-purple-600 px-4 py-2 text-sm font-medium text-white hover:bg-purple-700 disabled:opacity-60"
308
+ >
309
+ {evolveItem.isPending ? 'Evolving…' : \`Evolve to \${targetState || '…'}\`}
310
+ </button>
311
+ </div>
312
+
313
+ {evolveItem.isError && (
314
+ <div className="rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300">
315
+ Evolve failed: {String(evolveItem.error)}
316
+ </div>
317
+ )}
318
+ </form>
319
+ )}`;
320
+ }
321
+
322
+ /**
323
+ * Local copy of extractLifecycles to avoid importing from the hook
324
+ * generator. Matches the runtime helper in lifecycle-helpers.ts.
325
+ */
326
+ function extractLifecycles(model: any): Array<{ name: string; states: string[] }> {
327
+ const out: Array<{ name: string; states: string[] }> = [];
328
+ const lifecycles = (model?.lifecycles ?? {}) as Record<string, any>;
329
+ for (const [name, def] of Object.entries(lifecycles)) {
330
+ if (!def || typeof def !== 'object') continue;
331
+ let states: string[] = [];
332
+ if (Array.isArray(def.states)) {
333
+ states = def.states
334
+ .map((s: any) => (typeof s === 'string' ? s : s?.name ?? s?.id ?? ''))
335
+ .filter(Boolean);
336
+ } else if (typeof def.flow === 'string') {
337
+ states = def.flow.split(/\s*(?:->|,)\s*/).map((s: string) => s.trim()).filter(Boolean);
338
+ }
339
+ if (states.length > 0) out.push({ name, states });
340
+ }
341
+ return out;
342
+ }
343
+
344
+ function humanize(name: string): string {
345
+ return name
346
+ .replace(/([A-Z])/g, ' $1')
347
+ .replace(/^./, c => c.toUpperCase())
348
+ .trim();
349
+ }
350
+
351
+ /**
352
+ * Return the \`useState\` import line for detail views with tabs.
353
+ * Empty for everything else — strict TS rejects unused imports, so
354
+ * we emit the import only when the state is actually declared.
355
+ */
356
+ function buildRelatedTabImport(context: EmitContext): string {
357
+ if (context.view.type.toLowerCase() !== 'detail') return '';
358
+ const hasMany = extractHasManyTargets(context.model, context.modelSchemas);
359
+ if (hasMany.length < 2) return '';
360
+ return `import { useState } from 'react';`;
361
+ }
362
+
363
+ /**
364
+ * Emit a \`useState\` call only when the detail view will render
365
+ * tabs — i.e. when the model has 2+ hasMany relationships. The tab
366
+ * state defaults to the first relationship's name. For other view
367
+ * types or single-hasMany detail views, no state is needed.
368
+ */
369
+ function buildRelatedTabState(context: EmitContext): string {
370
+ if (context.view.type.toLowerCase() !== 'detail') return '';
371
+ const hasMany = extractHasManyTargets(context.model, context.modelSchemas);
372
+ if (hasMany.length < 2) return '';
373
+ const firstName = hasMany[0].name;
374
+ return ` const [relatedTab, setRelatedTab] = useState<string>('${firstName}');`;
375
+ }
376
+
146
377
  /**
147
378
  * Compute the belongsTo wiring for a view: one import line per unique
148
379
  * target hook, one hook call per relationship (even if two relations
@@ -155,29 +386,132 @@ function buildSubstitutions(context: EmitContext, body: string): Substitutions {
155
386
  * (has FK id, looks up in the list)
156
387
  */
157
388
  function buildBelongsToWiring(context: EmitContext): { imports: string; hooks: string } {
158
- const rels = extractBelongsToTargets(context.model);
159
- if (rels.length === 0) return { imports: '', hooks: '' };
389
+ const belongsTo = extractBelongsToTargets(context.model);
390
+ // hasMany wiring is only needed by the detail view (for its
391
+ // related-lists section). Other view types skip it to avoid
392
+ // unused-import TS errors.
393
+ const isDetail = context.view.type.toLowerCase() === 'detail';
394
+ const hasMany = isDetail
395
+ ? extractHasManyTargets(context.model, context.modelSchemas)
396
+ : [];
397
+
398
+ // For detail's related tables to resolve FK columns (e.g. Task.project
399
+ // in a User → Tasks table), we also need query hooks for each
400
+ // hasMany-target's own belongsTo targets. Skip the back-reference
401
+ // target (pointing at us) since that column is filtered out.
402
+ const crossBelongsTo: BelongsToLike[] = [];
403
+ if (isDetail) {
404
+ for (const rel of hasMany) {
405
+ const targetModel = context.modelSchemas[rel.target];
406
+ if (!targetModel) continue;
407
+ const targetFK = rel.foreignKey;
408
+ for (const btm of extractBelongsToTargets(targetModel)) {
409
+ if (`${btm.name}Id` === targetFK) continue; // skip back-ref
410
+ crossBelongsTo.push(btm);
411
+ }
412
+ }
413
+ }
414
+
415
+ if (belongsTo.length === 0 && hasMany.length === 0 && crossBelongsTo.length === 0) {
416
+ // Detail view still needs getEntityDisplayName for its picker +
417
+ // formatDisplayValue for field rows, even when the model has no
418
+ // relationships.
419
+ const vt = context.view.type.toLowerCase();
420
+ if (vt === 'detail') {
421
+ return {
422
+ imports: `import { getEntityDisplayName, formatDisplayValue } from '../lib/entity-display';`,
423
+ hooks: '',
424
+ };
425
+ }
426
+ // List / dashboard without relationships still need formatDisplayValue
427
+ // for non-FK cells.
428
+ if (vt === 'list' || vt === 'dashboard') {
429
+ return {
430
+ imports: `import { formatDisplayValue } from '../lib/entity-display';`,
431
+ hooks: '',
432
+ };
433
+ }
434
+ return { imports: '', hooks: '' };
435
+ }
160
436
 
161
- // Dedupe hook imports by target — two belongsTo to the same model
437
+ // Dedupe hook imports by target — relationships to the same model
162
438
  // share the same query hook.
163
439
  const hookImportNames = new Set<string>();
164
- for (const rel of rels) {
440
+ for (const rel of belongsTo) {
441
+ hookImportNames.add(`use${pluralizeShared(rel.target)}Query`);
442
+ }
443
+ for (const rel of hasMany) {
444
+ hookImportNames.add(`use${pluralizeShared(rel.target)}Query`);
445
+ }
446
+ for (const rel of crossBelongsTo) {
165
447
  hookImportNames.add(`use${pluralizeShared(rel.target)}Query`);
166
448
  }
167
449
 
168
- const helperImport = context.view.type.toLowerCase() === 'form'
169
- ? 'getEntityDisplayName'
170
- : 'resolveEntityDisplayName';
450
+ // Helpers needed per view type (strict TS rejects unused imports,
451
+ // so we only list what the emitted code actually references):
452
+ // - form uses getEntityDisplayName(opt) for dropdown labels
453
+ // - list / dashboard use resolveEntityDisplayName(id, list) for
454
+ // FK columns
455
+ // - detail main section uses resolveEntityDisplayName when there
456
+ // are belongsTo rows; the related-lists section uses
457
+ // resolveEntityDisplayName for FK columns in the full-column
458
+ // path, or getEntityDisplayName in the one-column fallback
459
+ // path (used when a hasMany target's schema isn't in
460
+ // context.modelSchemas).
461
+ const viewType = context.view.type.toLowerCase();
462
+ const helperImports = new Set<string>();
463
+ if (viewType === 'form') {
464
+ helperImports.add('getEntityDisplayName');
465
+ } else if (viewType === 'detail') {
466
+ // Detail view always uses getEntityDisplayName for the entity
467
+ // picker dropdown (per-skeleton, not per-section).
468
+ helperImports.add('getEntityDisplayName');
469
+ helperImports.add('formatDisplayValue'); // field-list + related-tables cells.
470
+ if (belongsTo.length > 0 || crossBelongsTo.length > 0) {
471
+ helperImports.add('resolveEntityDisplayName');
472
+ }
473
+ } else if (viewType === 'list' || viewType === 'dashboard') {
474
+ helperImports.add('resolveEntityDisplayName');
475
+ helperImports.add('formatDisplayValue'); // table-content + recent-items cells.
476
+ } else {
477
+ helperImports.add('resolveEntityDisplayName');
478
+ }
171
479
 
172
480
  const importLines: string[] = [];
173
481
  importLines.push(
174
482
  `import { ${[...hookImportNames].join(', ')} } from '../hooks/useApi';`
175
483
  );
176
- importLines.push(`import { ${helperImport} } from '../lib/entity-display';`);
484
+ if (helperImports.size > 0) {
485
+ importLines.push(`import { ${[...helperImports].join(', ')} } from '../lib/entity-display';`);
486
+ }
177
487
 
178
- const hookLines = rels.map(rel =>
179
- ` const { data: ${rel.name}Options = [] } = use${pluralizeShared(rel.target)}Query();`
180
- );
488
+ // belongsTo hooks \`${relName}Options\` arrays used in dropdowns
489
+ // and FK resolvers. hasMany hooks → \`${relName}All\` arrays that
490
+ // the detail composer filters by the back-reference FK. Cross-
491
+ // belongsTo hooks → \`${relName}Options\` so related tables in the
492
+ // detail view can resolve their FK columns to display names.
493
+ const hookLines: string[] = [];
494
+ for (const rel of belongsTo) {
495
+ hookLines.push(
496
+ ` const { data: ${rel.name}Options = [] } = use${pluralizeShared(rel.target)}Query();`
497
+ );
498
+ }
499
+ for (const rel of hasMany) {
500
+ hookLines.push(
501
+ ` const { data: ${rel.name}All = [] } = use${pluralizeShared(rel.target)}Query();`
502
+ );
503
+ }
504
+ // Dedupe cross-belongsTo options by name: if two hasMany targets
505
+ // both have a \`project\` belongsTo, we only need one \`projectOptions\`.
506
+ const emittedOptionsVars = new Set(belongsTo.map(r => `${r.name}Options`));
507
+ for (const rel of crossBelongsTo) {
508
+ const varName = `${rel.name}Options`;
509
+ if (emittedOptionsVars.has(varName)) continue;
510
+ emittedOptionsVars.add(varName);
511
+ hookLines.push(
512
+ ` const { data: ${varName} = [] } = use${pluralizeShared(rel.target)}Query();`
513
+ );
514
+ }
181
515
 
182
516
  return {
183
517
  imports: importLines.join('\n'),
@@ -185,6 +519,13 @@ function buildBelongsToWiring(context: EmitContext): { imports: string; hooks: s
185
519
  };
186
520
  }
187
521
 
522
+ /**
523
+ * Minimal "belongsTo-like" shape — both extractBelongsToTargets
524
+ * results and the cross-target equivalent use this. Declared here
525
+ * just to keep the wiring function's signatures tight.
526
+ */
527
+ type BelongsToLike = { name: string; target: string };
528
+
188
529
  function applySubstitutions(template: string, subs: Substitutions): string {
189
530
  let out = template;
190
531
  for (const [key, value] of Object.entries(subs)) {
@@ -14,7 +14,9 @@ import { composeListBody } from './list-body-composer.js';
14
14
  import { composeDetailBody } from './detail-body-composer.js';
15
15
  import { composeFormBody } from './form-body-composer.js';
16
16
  import { composeDashboardBody } from './dashboard-body-composer.js';
17
- import { emitEntityDisplay } from './helpers-emitter.js';
17
+ import { emitEntityDisplay, emitUseResizableSidebar, emitUseAppEvents } from './helpers-emitter.js';
18
+ import { emitFieldInput, emitOperationExecutor, emitOperationResultView } from './operation-emitters.js';
19
+ import { collectOperationViews, emitOperationView } from './operation-view-generator.js';
18
20
 
19
21
  /**
20
22
  * The four primary view types Factory B currently emits. Specialist
@@ -46,6 +48,12 @@ export interface ExpandedSpec {
46
48
  models?: Record<string, ModelSpec>;
47
49
  /** Views keyed by view name (e.g. "PostListView"). */
48
50
  views?: Record<string, ViewSpec & { type: string; model?: string }>;
51
+ /** Controllers keyed by name (e.g. "PostController"). Non-standard
52
+ * commands get auto-generated operation views. */
53
+ controllers?: Record<string, any>;
54
+ /** Services keyed by name (e.g. "AuthService"). Every operation
55
+ * gets an auto-generated operation view. */
56
+ services?: Record<string, any> | any[];
49
57
  }
50
58
 
51
59
  export interface GeneratedFiles {
@@ -105,8 +113,27 @@ export async function generate(context: ViewsGeneratorContext): Promise<Generate
105
113
  );
106
114
  }
107
115
 
116
+ // Auto-generated operation views — one per non-standard controller
117
+ // command + service operation. Uses the same lib components as
118
+ // handwritten operation views would; emitted into src/views/ alongside
119
+ // declared views. App.tsx sidebar groups them as Dev (see below).
120
+ const operationViews = collectOperationViews(spec);
121
+ for (const desc of operationViews) {
122
+ files[`src/views/${desc.viewName}.tsx`] = emitOperationView(desc);
123
+ }
124
+
108
125
  // Local helpers
109
126
  files['src/lib/entity-display.ts'] = emitEntityDisplay();
127
+ files['src/hooks/useResizableSidebar.ts'] = emitUseResizableSidebar();
128
+ files['src/hooks/useAppEvents.tsx'] = emitUseAppEvents();
129
+ // Operation-view lib (only needed when at least one operation view
130
+ // is emitted — otherwise FieldInput / OperationExecutor / etc. go
131
+ // unused and trigger TS errors for unused imports from nowhere).
132
+ if (operationViews.length > 0) {
133
+ files['src/lib/FieldInput.tsx'] = emitFieldInput();
134
+ files['src/lib/OperationExecutor.tsx'] = emitOperationExecutor();
135
+ files['src/lib/OperationResultView.tsx'] = emitOperationResultView();
136
+ }
110
137
 
111
138
  return files;
112
139
  }
@@ -264,9 +264,21 @@ function generateHandlerBody(
264
264
  await handler.delete(id);
265
265
  return reply.status(204).send();
266
266
  } catch (error) {
267
+ // Prisma's P2003 is the foreign-key-constraint-violated error. When
268
+ // a ${lowerModel} has children (hasMany relationships not declared
269
+ // with 'cascade'), SQLite / Postgres refuse the delete and we land
270
+ // here. Return a 409 Conflict with a specific hint rather than a 500
271
+ // with a raw Prisma dump — the frontend shows the message verbatim.
272
+ const msg = error instanceof Error ? error.message : String(error);
273
+ if (msg.includes('P2003') || msg.toLowerCase().includes('foreign key constraint')) {
274
+ return reply.status(409).send({
275
+ error: 'Cannot delete ${lowerModel}',
276
+ message: \`This ${lowerModel} has related records. Delete those first, or declare the relationship with 'cascade' in your spec to delete them automatically.\`
277
+ });
278
+ }
267
279
  return reply.status(500).send({
268
280
  error: 'Failed to delete ${lowerModel}',
269
- message: error instanceof Error ? error.message : String(error)
281
+ message: msg
270
282
  });
271
283
  }`;
272
284
 
@@ -136,6 +136,24 @@ ${hasEvents ? ` // Register WebSocket bridge for real-time frontend events
136
136
  console.log(\`API endpoints: ${modelNames.map((n: string) => `/api/${pluralizeLower(n)}`).join(', ')}\`);
137
137
  ${hasEvents ? ` console.log(\`WebSocket: ws://localhost:\${port}/ws\`);
138
138
  console.log(\`Events: ${specEvents.length} wired (GET /api/runtime/events for the full list)\`);` : ''}
139
+
140
+ // Graceful shutdown — without these handlers, Ctrl-C leaves open
141
+ // WebSocket connections + Prisma clients hanging, and tsx watch
142
+ // reports "Previous process hasn't exited yet. Force killing...".
143
+ // Drain Fastify (closes HTTP + WS), then disconnect Prisma, then exit.
144
+ const shutdown = async (signal: string) => {
145
+ try {
146
+ fastify.log.info(\`Received \${signal}, closing server...\`);
147
+ await fastify.close();
148
+ try { await (prisma as any).$disconnect?.(); } catch { /* ignore */ }
149
+ process.exit(0);
150
+ } catch (err) {
151
+ fastify.log.error({ err }, 'Error during shutdown');
152
+ process.exit(1);
153
+ }
154
+ };
155
+ process.on('SIGINT', () => shutdown('SIGINT'));
156
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
139
157
  } catch (err) {
140
158
  fastify.log.error(err);
141
159
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specverse/engines",
3
- "version": "4.2.2",
3
+ "version": "4.3.1",
4
4
  "description": "SpecVerse toolchain — parser, inference, realize, generators, AI, registry",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -44,7 +44,7 @@
44
44
  "dependencies": {
45
45
  "@specverse/types": "^4.1.0",
46
46
  "@specverse/entities": "^4.1.0",
47
- "@specverse/runtime": "^4.1.22",
47
+ "@specverse/runtime": "^4.1.23",
48
48
  "ajv": "^8.17.0",
49
49
  "ajv-formats": "^2.1.0",
50
50
  "glob": "^10.0.0",