@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.
- package/dist/inference/core/specly-converter.d.ts.map +1 -1
- package/dist/inference/core/specly-converter.js +43 -22
- package/dist/inference/core/specly-converter.js.map +1 -1
- package/dist/inference/index.d.ts.map +1 -1
- package/dist/inference/index.js +29 -0
- package/dist/inference/index.js.map +1 -1
- package/dist/inference/ui-contracts/rules/action-buttons-present.d.ts +8 -5
- package/dist/inference/ui-contracts/rules/action-buttons-present.d.ts.map +1 -1
- package/dist/inference/ui-contracts/rules/action-buttons-present.js +85 -19
- package/dist/inference/ui-contracts/rules/action-buttons-present.js.map +1 -1
- package/dist/inference/ui-contracts/test-case-types.d.ts +3 -0
- package/dist/inference/ui-contracts/test-case-types.d.ts.map +1 -1
- package/dist/inference/ui-contracts/translator.d.ts.map +1 -1
- package/dist/inference/ui-contracts/translator.js +4 -0
- package/dist/inference/ui-contracts/translator.js.map +1 -1
- package/dist/libs/instance-factories/applications/templates/react/index-html-generator.js +2 -2
- package/dist/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.js +291 -59
- package/dist/libs/instance-factories/applications/templates/react-starter/belongs-to.js +37 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.js +9 -124
- package/dist/libs/instance-factories/applications/templates/react-starter/detail-body-composer.js +9 -75
- package/dist/libs/instance-factories/applications/templates/react-starter/emit-jsx-source.js +129 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/form-body-composer.js +9 -212
- package/dist/libs/instance-factories/applications/templates/react-starter/helpers-emitter.js +260 -5
- package/dist/libs/instance-factories/applications/templates/react-starter/list-body-composer.js +8 -50
- package/dist/libs/instance-factories/applications/templates/react-starter/operation-emitters.js +470 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/operation-view-generator.js +136 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/package-json-generator.js +2 -1
- package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +54 -61
- package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +115 -27
- package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +17 -9
- package/dist/libs/instance-factories/applications/templates/react-starter/use-api-hooks-starter-generator.js +49 -10
- package/dist/libs/instance-factories/applications/templates/react-starter/view-emitter.js +223 -10
- package/dist/libs/instance-factories/applications/templates/react-starter/views-generator.js +14 -1
- package/dist/libs/instance-factories/controllers/templates/fastify/routes-generator.js +13 -1
- package/dist/libs/instance-factories/controllers/templates/fastify/server-generator.js +18 -0
- package/libs/instance-factories/applications/templates/react/index-html-generator.ts +2 -2
- package/libs/instance-factories/applications/templates/react-starter/__tests__/dashboard-body-composer.test.ts +3 -1
- package/libs/instance-factories/applications/templates/react-starter/__tests__/detail-body-composer.test.ts +24 -25
- package/libs/instance-factories/applications/templates/react-starter/__tests__/list-body-composer.test.ts +3 -3
- package/libs/instance-factories/applications/templates/react-starter/__tests__/orchestrator.test.ts +2 -0
- package/libs/instance-factories/applications/templates/react-starter/__tests__/parity-p3-rendered-output.test.ts +1 -1
- package/libs/instance-factories/applications/templates/react-starter/__tests__/starter-generators.test.ts +5 -4
- package/libs/instance-factories/applications/templates/react-starter/__tests__/views-generator.test.ts +11 -4
- package/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.ts +377 -71
- package/libs/instance-factories/applications/templates/react-starter/belongs-to.ts +66 -0
- package/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.ts +15 -182
- package/libs/instance-factories/applications/templates/react-starter/detail-body-composer.ts +16 -128
- package/libs/instance-factories/applications/templates/react-starter/emit-jsx-source.ts +233 -0
- package/libs/instance-factories/applications/templates/react-starter/form-body-composer.ts +16 -376
- package/libs/instance-factories/applications/templates/react-starter/helpers-emitter.ts +282 -4
- package/libs/instance-factories/applications/templates/react-starter/list-body-composer.ts +26 -135
- package/libs/instance-factories/applications/templates/react-starter/operation-emitters.ts +497 -0
- package/libs/instance-factories/applications/templates/react-starter/operation-view-generator.ts +209 -0
- package/libs/instance-factories/applications/templates/react-starter/package-json-generator.ts +1 -0
- package/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +54 -61
- package/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +115 -27
- package/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +17 -9
- package/libs/instance-factories/applications/templates/react-starter/use-api-hooks-starter-generator.ts +71 -10
- package/libs/instance-factories/applications/templates/react-starter/view-emitter.ts +359 -18
- package/libs/instance-factories/applications/templates/react-starter/views-generator.ts +28 -1
- package/libs/instance-factories/controllers/templates/fastify/routes-generator.ts +13 -1
- package/libs/instance-factories/controllers/templates/fastify/server-generator.ts +18 -0
- 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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
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
|
|
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: '
|
|
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 {
|
|
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
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
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
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
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
|
|
159
|
-
|
|
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 —
|
|
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
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
484
|
+
if (helperImports.size > 0) {
|
|
485
|
+
importLines.push(`import { ${[...helperImports].join(', ')} } from '../lib/entity-display';`);
|
|
486
|
+
}
|
|
177
487
|
|
|
178
|
-
|
|
179
|
-
|
|
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:
|
|
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.
|
|
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.
|
|
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",
|