@specverse/engines 4.2.2 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/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/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
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { readFileSync } from "fs";
|
|
2
2
|
import { join, dirname } from "path";
|
|
3
3
|
import { fileURLToPath } from "url";
|
|
4
|
-
import {
|
|
4
|
+
import { walkPatternSection } from "@specverse/runtime/views/core";
|
|
5
|
+
import { extractBelongsToTargets, extractHasManyTargets, pluralize as pluralizeShared } from "./belongs-to.js";
|
|
6
|
+
import { emitJsxSource } from "./emit-jsx-source.js";
|
|
5
7
|
function emitView(context) {
|
|
6
8
|
const skeleton = loadSkeleton(context.view.type);
|
|
7
9
|
const bodyJsx = context.renderBody(context);
|
|
@@ -32,6 +34,8 @@ function buildSubstitutions(context, body) {
|
|
|
32
34
|
const modelName = context.model.name;
|
|
33
35
|
const pluralModel = pluralize(modelName);
|
|
34
36
|
const { imports, hooks } = buildBelongsToWiring(context);
|
|
37
|
+
const lifecycle = buildLifecycleSubstitutions(context);
|
|
38
|
+
const { fieldsBody, existingListBody } = buildFormSplitBodies(context);
|
|
35
39
|
return {
|
|
36
40
|
MODEL_NAME: modelName,
|
|
37
41
|
PLURAL_MODEL: pluralModel,
|
|
@@ -39,25 +43,234 @@ function buildSubstitutions(context, body) {
|
|
|
39
43
|
SINGULAR_LOWER: modelName.toLowerCase(),
|
|
40
44
|
BODY: body,
|
|
41
45
|
RELATED_IMPORTS: imports,
|
|
42
|
-
RELATED_HOOKS: hooks
|
|
46
|
+
RELATED_HOOKS: hooks,
|
|
47
|
+
RELATED_TAB_STATE: buildRelatedTabState(context),
|
|
48
|
+
RELATED_TAB_IMPORT: buildRelatedTabImport(context),
|
|
49
|
+
FIELDS_BODY: fieldsBody,
|
|
50
|
+
EXISTING_LIST_BODY: existingListBody,
|
|
51
|
+
...lifecycle
|
|
43
52
|
};
|
|
44
53
|
}
|
|
54
|
+
function buildFormSplitBodies(context) {
|
|
55
|
+
if (context.view.type.toLowerCase() !== "form") {
|
|
56
|
+
return { fieldsBody: "", existingListBody: "" };
|
|
57
|
+
}
|
|
58
|
+
const walkCtx = {
|
|
59
|
+
model: context.model,
|
|
60
|
+
modelSchemas: context.modelSchemas,
|
|
61
|
+
view: context.view
|
|
62
|
+
};
|
|
63
|
+
const imports = /* @__PURE__ */ new Set();
|
|
64
|
+
const fieldsNodes = walkPatternSection("form-view", "fields", walkCtx);
|
|
65
|
+
const listNodes = walkPatternSection("form-view", "existing-entities-list", walkCtx);
|
|
66
|
+
return {
|
|
67
|
+
fieldsBody: emitJsxSource(fieldsNodes, { imports, indent: 10 }),
|
|
68
|
+
existingListBody: emitJsxSource(listNodes, { imports, indent: 6 })
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function buildLifecycleSubstitutions(context) {
|
|
72
|
+
const isForm = context.view.type.toLowerCase() === "form";
|
|
73
|
+
const lifecycles = extractLifecycles(context.model);
|
|
74
|
+
if (!isForm || lifecycles.length === 0) {
|
|
75
|
+
return {
|
|
76
|
+
LIFECYCLE_IMPORTS: "",
|
|
77
|
+
LIFECYCLE_HOOK: "",
|
|
78
|
+
LIFECYCLE_STATE: "",
|
|
79
|
+
LIFECYCLE_HANDLER: "",
|
|
80
|
+
LIFECYCLE_PANEL: "",
|
|
81
|
+
HAS_LIFECYCLES: "false"
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
const modelName = context.model.name;
|
|
85
|
+
const primary = lifecycles[0];
|
|
86
|
+
const lifecyclesConst = `${modelName.toUpperCase()}_LIFECYCLES`;
|
|
87
|
+
return {
|
|
88
|
+
LIFECYCLE_IMPORTS: ` useEvolve${modelName}Mutation,
|
|
89
|
+
${lifecyclesConst},`,
|
|
90
|
+
LIFECYCLE_HOOK: ` const evolveItem = useEvolve${modelName}Mutation();`,
|
|
91
|
+
LIFECYCLE_STATE: ` const [targetState, setTargetState] = useState<string>('');`,
|
|
92
|
+
LIFECYCLE_HANDLER: ` const handleEvolveSubmit = async (event: React.FormEvent) => {
|
|
93
|
+
event.preventDefault();
|
|
94
|
+
if (!targetState) return;
|
|
95
|
+
try {
|
|
96
|
+
const result = await evolveItem.mutateAsync({
|
|
97
|
+
id: entityId as any,
|
|
98
|
+
toState: targetState,
|
|
99
|
+
lifecycleName: '${primary.name}',
|
|
100
|
+
});
|
|
101
|
+
onSuccess?.(result as ${modelName});
|
|
102
|
+
} catch {
|
|
103
|
+
// Evolve errors are surfaced via evolveItem.isError.
|
|
104
|
+
}
|
|
105
|
+
};`,
|
|
106
|
+
LIFECYCLE_PANEL: buildLifecyclePanel(modelName, primary, lifecyclesConst),
|
|
107
|
+
HAS_LIFECYCLES: "true"
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function buildLifecyclePanel(modelName, primary, lifecyclesConst) {
|
|
111
|
+
return ` {showTabs && activeTab === 'evolve' && (
|
|
112
|
+
<form onSubmit={handleEvolveSubmit} className="space-y-6">
|
|
113
|
+
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-900">
|
|
114
|
+
<dl className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
115
|
+
<div>
|
|
116
|
+
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Current ${humanize(primary.name)}</dt>
|
|
117
|
+
<dd className="mt-1">
|
|
118
|
+
<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">
|
|
119
|
+
{String((existing as any)?.${primary.name} ?? '')}
|
|
120
|
+
</span>
|
|
121
|
+
</dd>
|
|
122
|
+
</div>
|
|
123
|
+
<div>
|
|
124
|
+
<label htmlFor="evolve-target" className="block text-sm font-medium text-gray-500 dark:text-gray-400">Next ${humanize(primary.name)}</label>
|
|
125
|
+
<select
|
|
126
|
+
id="evolve-target"
|
|
127
|
+
value={targetState}
|
|
128
|
+
onChange={e => setTargetState(e.target.value)}
|
|
129
|
+
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"
|
|
130
|
+
>
|
|
131
|
+
<option value="">\u2014 choose \u2014</option>
|
|
132
|
+
{${lifecyclesConst}.${primary.name}.states.map(s => (
|
|
133
|
+
<option key={s} value={s}>{s}</option>
|
|
134
|
+
))}
|
|
135
|
+
</select>
|
|
136
|
+
</div>
|
|
137
|
+
</dl>
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
<div>
|
|
141
|
+
<button
|
|
142
|
+
type="submit"
|
|
143
|
+
disabled={evolveItem.isPending || !targetState}
|
|
144
|
+
className="rounded bg-purple-600 px-4 py-2 text-sm font-medium text-white hover:bg-purple-700 disabled:opacity-60"
|
|
145
|
+
>
|
|
146
|
+
{evolveItem.isPending ? 'Evolving\u2026' : \`Evolve to \${targetState || '\u2026'}\`}
|
|
147
|
+
</button>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
{evolveItem.isError && (
|
|
151
|
+
<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">
|
|
152
|
+
Evolve failed: {String(evolveItem.error)}
|
|
153
|
+
</div>
|
|
154
|
+
)}
|
|
155
|
+
</form>
|
|
156
|
+
)}`;
|
|
157
|
+
}
|
|
158
|
+
function extractLifecycles(model) {
|
|
159
|
+
const out = [];
|
|
160
|
+
const lifecycles = model?.lifecycles ?? {};
|
|
161
|
+
for (const [name, def] of Object.entries(lifecycles)) {
|
|
162
|
+
if (!def || typeof def !== "object") continue;
|
|
163
|
+
let states = [];
|
|
164
|
+
if (Array.isArray(def.states)) {
|
|
165
|
+
states = def.states.map((s) => typeof s === "string" ? s : s?.name ?? s?.id ?? "").filter(Boolean);
|
|
166
|
+
} else if (typeof def.flow === "string") {
|
|
167
|
+
states = def.flow.split(/\s*(?:->|,)\s*/).map((s) => s.trim()).filter(Boolean);
|
|
168
|
+
}
|
|
169
|
+
if (states.length > 0) out.push({ name, states });
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
function humanize(name) {
|
|
174
|
+
return name.replace(/([A-Z])/g, " $1").replace(/^./, (c) => c.toUpperCase()).trim();
|
|
175
|
+
}
|
|
176
|
+
function buildRelatedTabImport(context) {
|
|
177
|
+
if (context.view.type.toLowerCase() !== "detail") return "";
|
|
178
|
+
const hasMany = extractHasManyTargets(context.model, context.modelSchemas);
|
|
179
|
+
if (hasMany.length < 2) return "";
|
|
180
|
+
return `import { useState } from 'react';`;
|
|
181
|
+
}
|
|
182
|
+
function buildRelatedTabState(context) {
|
|
183
|
+
if (context.view.type.toLowerCase() !== "detail") return "";
|
|
184
|
+
const hasMany = extractHasManyTargets(context.model, context.modelSchemas);
|
|
185
|
+
if (hasMany.length < 2) return "";
|
|
186
|
+
const firstName = hasMany[0].name;
|
|
187
|
+
return ` const [relatedTab, setRelatedTab] = useState<string>('${firstName}');`;
|
|
188
|
+
}
|
|
45
189
|
function buildBelongsToWiring(context) {
|
|
46
|
-
const
|
|
47
|
-
|
|
190
|
+
const belongsTo = extractBelongsToTargets(context.model);
|
|
191
|
+
const isDetail = context.view.type.toLowerCase() === "detail";
|
|
192
|
+
const hasMany = isDetail ? extractHasManyTargets(context.model, context.modelSchemas) : [];
|
|
193
|
+
const crossBelongsTo = [];
|
|
194
|
+
if (isDetail) {
|
|
195
|
+
for (const rel of hasMany) {
|
|
196
|
+
const targetModel = context.modelSchemas[rel.target];
|
|
197
|
+
if (!targetModel) continue;
|
|
198
|
+
const targetFK = rel.foreignKey;
|
|
199
|
+
for (const btm of extractBelongsToTargets(targetModel)) {
|
|
200
|
+
if (`${btm.name}Id` === targetFK) continue;
|
|
201
|
+
crossBelongsTo.push(btm);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (belongsTo.length === 0 && hasMany.length === 0 && crossBelongsTo.length === 0) {
|
|
206
|
+
const vt = context.view.type.toLowerCase();
|
|
207
|
+
if (vt === "detail") {
|
|
208
|
+
return {
|
|
209
|
+
imports: `import { getEntityDisplayName, formatDisplayValue } from '../lib/entity-display';`,
|
|
210
|
+
hooks: ""
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
if (vt === "list" || vt === "dashboard") {
|
|
214
|
+
return {
|
|
215
|
+
imports: `import { formatDisplayValue } from '../lib/entity-display';`,
|
|
216
|
+
hooks: ""
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
return { imports: "", hooks: "" };
|
|
220
|
+
}
|
|
48
221
|
const hookImportNames = /* @__PURE__ */ new Set();
|
|
49
|
-
for (const rel of
|
|
222
|
+
for (const rel of belongsTo) {
|
|
223
|
+
hookImportNames.add(`use${pluralizeShared(rel.target)}Query`);
|
|
224
|
+
}
|
|
225
|
+
for (const rel of hasMany) {
|
|
226
|
+
hookImportNames.add(`use${pluralizeShared(rel.target)}Query`);
|
|
227
|
+
}
|
|
228
|
+
for (const rel of crossBelongsTo) {
|
|
50
229
|
hookImportNames.add(`use${pluralizeShared(rel.target)}Query`);
|
|
51
230
|
}
|
|
52
|
-
const
|
|
231
|
+
const viewType = context.view.type.toLowerCase();
|
|
232
|
+
const helperImports = /* @__PURE__ */ new Set();
|
|
233
|
+
if (viewType === "form") {
|
|
234
|
+
helperImports.add("getEntityDisplayName");
|
|
235
|
+
} else if (viewType === "detail") {
|
|
236
|
+
helperImports.add("getEntityDisplayName");
|
|
237
|
+
helperImports.add("formatDisplayValue");
|
|
238
|
+
if (belongsTo.length > 0 || crossBelongsTo.length > 0) {
|
|
239
|
+
helperImports.add("resolveEntityDisplayName");
|
|
240
|
+
}
|
|
241
|
+
} else if (viewType === "list" || viewType === "dashboard") {
|
|
242
|
+
helperImports.add("resolveEntityDisplayName");
|
|
243
|
+
helperImports.add("formatDisplayValue");
|
|
244
|
+
} else {
|
|
245
|
+
helperImports.add("resolveEntityDisplayName");
|
|
246
|
+
}
|
|
53
247
|
const importLines = [];
|
|
54
248
|
importLines.push(
|
|
55
249
|
`import { ${[...hookImportNames].join(", ")} } from '../hooks/useApi';`
|
|
56
250
|
);
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
251
|
+
if (helperImports.size > 0) {
|
|
252
|
+
importLines.push(`import { ${[...helperImports].join(", ")} } from '../lib/entity-display';`);
|
|
253
|
+
}
|
|
254
|
+
const hookLines = [];
|
|
255
|
+
for (const rel of belongsTo) {
|
|
256
|
+
hookLines.push(
|
|
257
|
+
` const { data: ${rel.name}Options = [] } = use${pluralizeShared(rel.target)}Query();`
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
for (const rel of hasMany) {
|
|
261
|
+
hookLines.push(
|
|
262
|
+
` const { data: ${rel.name}All = [] } = use${pluralizeShared(rel.target)}Query();`
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
const emittedOptionsVars = new Set(belongsTo.map((r) => `${r.name}Options`));
|
|
266
|
+
for (const rel of crossBelongsTo) {
|
|
267
|
+
const varName = `${rel.name}Options`;
|
|
268
|
+
if (emittedOptionsVars.has(varName)) continue;
|
|
269
|
+
emittedOptionsVars.add(varName);
|
|
270
|
+
hookLines.push(
|
|
271
|
+
` const { data: ${varName} = [] } = use${pluralizeShared(rel.target)}Query();`
|
|
272
|
+
);
|
|
273
|
+
}
|
|
61
274
|
return {
|
|
62
275
|
imports: importLines.join("\n"),
|
|
63
276
|
hooks: hookLines.join("\n")
|
package/dist/libs/instance-factories/applications/templates/react-starter/views-generator.js
CHANGED
|
@@ -3,7 +3,9 @@ import { composeListBody } from "./list-body-composer.js";
|
|
|
3
3
|
import { composeDetailBody } from "./detail-body-composer.js";
|
|
4
4
|
import { composeFormBody } from "./form-body-composer.js";
|
|
5
5
|
import { composeDashboardBody } from "./dashboard-body-composer.js";
|
|
6
|
-
import { emitEntityDisplay } from "./helpers-emitter.js";
|
|
6
|
+
import { emitEntityDisplay, emitUseResizableSidebar, emitUseAppEvents } from "./helpers-emitter.js";
|
|
7
|
+
import { emitFieldInput, emitOperationExecutor, emitOperationResultView } from "./operation-emitters.js";
|
|
8
|
+
import { collectOperationViews, emitOperationView } from "./operation-view-generator.js";
|
|
7
9
|
const PRIMARY_VIEW_TYPES = ["list", "detail", "form", "dashboard"];
|
|
8
10
|
const COMPOSER_BY_TYPE = {
|
|
9
11
|
list: composeListBody,
|
|
@@ -42,7 +44,18 @@ async function generate(context) {
|
|
|
42
44
|
`[ReactAppStarter] Skipped ${skippedSpecialists.length} specialist view(s) \u2014 implement composers for: ${[...new Set(Object.values(viewsBySpec).filter((v) => !PRIMARY_VIEW_TYPES.includes(v.type)).map((v) => v.type))].join(", ")}. Skipped: ${skippedSpecialists.join(", ")}`
|
|
43
45
|
);
|
|
44
46
|
}
|
|
47
|
+
const operationViews = collectOperationViews(spec);
|
|
48
|
+
for (const desc of operationViews) {
|
|
49
|
+
files[`src/views/${desc.viewName}.tsx`] = emitOperationView(desc);
|
|
50
|
+
}
|
|
45
51
|
files["src/lib/entity-display.ts"] = emitEntityDisplay();
|
|
52
|
+
files["src/hooks/useResizableSidebar.ts"] = emitUseResizableSidebar();
|
|
53
|
+
files["src/hooks/useAppEvents.tsx"] = emitUseAppEvents();
|
|
54
|
+
if (operationViews.length > 0) {
|
|
55
|
+
files["src/lib/FieldInput.tsx"] = emitFieldInput();
|
|
56
|
+
files["src/lib/OperationExecutor.tsx"] = emitOperationExecutor();
|
|
57
|
+
files["src/lib/OperationResultView.tsx"] = emitOperationResultView();
|
|
58
|
+
}
|
|
46
59
|
return files;
|
|
47
60
|
}
|
|
48
61
|
function indexViews(views) {
|
|
@@ -187,9 +187,21 @@ function generateHandlerBody(operation, modelName, handlerName, isModelControlle
|
|
|
187
187
|
await handler.delete(id);
|
|
188
188
|
return reply.status(204).send();
|
|
189
189
|
} catch (error) {
|
|
190
|
+
// Prisma's P2003 is the foreign-key-constraint-violated error. When
|
|
191
|
+
// a ${lowerModel} has children (hasMany relationships not declared
|
|
192
|
+
// with 'cascade'), SQLite / Postgres refuse the delete and we land
|
|
193
|
+
// here. Return a 409 Conflict with a specific hint rather than a 500
|
|
194
|
+
// with a raw Prisma dump \u2014 the frontend shows the message verbatim.
|
|
195
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
196
|
+
if (msg.includes('P2003') || msg.toLowerCase().includes('foreign key constraint')) {
|
|
197
|
+
return reply.status(409).send({
|
|
198
|
+
error: 'Cannot delete ${lowerModel}',
|
|
199
|
+
message: \`This ${lowerModel} has related records. Delete those first, or declare the relationship with 'cascade' in your spec to delete them automatically.\`
|
|
200
|
+
});
|
|
201
|
+
}
|
|
190
202
|
return reply.status(500).send({
|
|
191
203
|
error: 'Failed to delete ${lowerModel}',
|
|
192
|
-
message:
|
|
204
|
+
message: msg
|
|
193
205
|
});
|
|
194
206
|
}`;
|
|
195
207
|
case "list":
|
|
@@ -118,6 +118,24 @@ ${hasEvents ? ` // Register WebSocket bridge for real-time frontend events
|
|
|
118
118
|
console.log(\`API endpoints: ${modelNames.map((n) => `/api/${pluralizeLower(n)}`).join(", ")}\`);
|
|
119
119
|
${hasEvents ? ` console.log(\`WebSocket: ws://localhost:\${port}/ws\`);
|
|
120
120
|
console.log(\`Events: ${specEvents.length} wired (GET /api/runtime/events for the full list)\`);` : ""}
|
|
121
|
+
|
|
122
|
+
// Graceful shutdown \u2014 without these handlers, Ctrl-C leaves open
|
|
123
|
+
// WebSocket connections + Prisma clients hanging, and tsx watch
|
|
124
|
+
// reports "Previous process hasn't exited yet. Force killing...".
|
|
125
|
+
// Drain Fastify (closes HTTP + WS), then disconnect Prisma, then exit.
|
|
126
|
+
const shutdown = async (signal: string) => {
|
|
127
|
+
try {
|
|
128
|
+
fastify.log.info(\`Received \${signal}, closing server...\`);
|
|
129
|
+
await fastify.close();
|
|
130
|
+
try { await (prisma as any).$disconnect?.(); } catch { /* ignore */ }
|
|
131
|
+
process.exit(0);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
fastify.log.error({ err }, 'Error during shutdown');
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
138
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
121
139
|
} catch (err) {
|
|
122
140
|
fastify.log.error(err);
|
|
123
141
|
process.exit(1);
|
|
@@ -13,7 +13,7 @@ export default function generateIndexHtml(context: TemplateContext): string {
|
|
|
13
13
|
const description = spec.metadata?.description || 'Generated by SpecVerse';
|
|
14
14
|
|
|
15
15
|
return `<!DOCTYPE html>
|
|
16
|
-
<html lang="en">
|
|
16
|
+
<html lang="en" class="dark">
|
|
17
17
|
<head>
|
|
18
18
|
<meta charset="UTF-8" />
|
|
19
19
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
@@ -21,7 +21,7 @@ export default function generateIndexHtml(context: TemplateContext): string {
|
|
|
21
21
|
<meta name="description" content="${description}" />
|
|
22
22
|
<title>${appName}</title>
|
|
23
23
|
</head>
|
|
24
|
-
<body>
|
|
24
|
+
<body class="bg-gray-950 text-gray-100">
|
|
25
25
|
<div id="root"></div>
|
|
26
26
|
<script type="module" src="/src/main.tsx"></script>
|
|
27
27
|
</body>
|
|
@@ -130,7 +130,9 @@ describe('composeDashboardBody — recent preview', () => {
|
|
|
130
130
|
describe('composeDashboardBody — TODO comments', () => {
|
|
131
131
|
it('flags the extension point for aggregation metrics', () => {
|
|
132
132
|
const body = composeDashboardBody(makeContext());
|
|
133
|
-
|
|
133
|
+
// Phase 2: the TODO now sits in the 'charts' section (not the
|
|
134
|
+
// 'metrics' row) since metrics are rendered as count cards.
|
|
135
|
+
expect(body).toContain('TODO: add aggregation charts');
|
|
134
136
|
});
|
|
135
137
|
});
|
|
136
138
|
|
|
@@ -50,7 +50,7 @@ function assertValidTsx(source: string, label: string): void {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
describe('composeDetailBody — direct output', () => {
|
|
53
|
-
it('groups fields into business
|
|
53
|
+
it('groups fields into business and belongsTo sections; omits metadata', () => {
|
|
54
54
|
const body = composeDetailBody(makeContext());
|
|
55
55
|
|
|
56
56
|
// Business fields (first <dl>)
|
|
@@ -63,24 +63,20 @@ describe('composeDetailBody — direct output', () => {
|
|
|
63
63
|
expect(body).toContain('>Author<');
|
|
64
64
|
expect(body).toContain('resolveEntityDisplayName((item as any).authorId, authorOptions)');
|
|
65
65
|
|
|
66
|
-
// Metadata
|
|
67
|
-
|
|
68
|
-
expect(body).toContain('>
|
|
69
|
-
expect(body).toContain('>
|
|
70
|
-
expect(body).toContain('
|
|
71
|
-
|
|
72
|
-
// Business section is separate from metadata (no mixing)
|
|
73
|
-
const businessIdx = body.indexOf('>Title<');
|
|
74
|
-
const metadataIdx = body.indexOf('>Id<');
|
|
75
|
-
expect(businessIdx).toBeLessThan(metadataIdx);
|
|
66
|
+
// Metadata (id / createdAt / publishedAt) is deliberately NOT rendered
|
|
67
|
+
// in the detail view — the list view surfaces it when users need it.
|
|
68
|
+
expect(body).not.toContain('>Id<');
|
|
69
|
+
expect(body).not.toContain('>Created At<');
|
|
70
|
+
expect(body).not.toContain('>Published At<');
|
|
76
71
|
});
|
|
77
72
|
|
|
78
|
-
it('emits one <dt>/<dd> per field with (item as any).FIELD access', () => {
|
|
73
|
+
it('emits one <dt>/<dd> per business field with (item as any).FIELD access', () => {
|
|
79
74
|
const body = composeDetailBody(makeContext());
|
|
80
|
-
expect(body).toContain('(item as any).title
|
|
81
|
-
expect(body).toContain('(item as any).body
|
|
82
|
-
|
|
83
|
-
expect(body).toContain('(item as any).
|
|
75
|
+
expect(body).toContain('formatDisplayValue((item as any).title)');
|
|
76
|
+
expect(body).toContain('formatDisplayValue((item as any).body)');
|
|
77
|
+
// Metadata access expressions are absent.
|
|
78
|
+
expect(body).not.toContain('formatDisplayValue((item as any).id)');
|
|
79
|
+
expect(body).not.toContain('formatDisplayValue((item as any).createdAt)');
|
|
84
80
|
});
|
|
85
81
|
|
|
86
82
|
it('falls back to a "no business fields" message when all attrs are metadata', () => {
|
|
@@ -125,22 +121,25 @@ describe('emitView wired to composeDetailBody — end-to-end', () => {
|
|
|
125
121
|
assertValidTsx(source, 'PostDetailView');
|
|
126
122
|
});
|
|
127
123
|
|
|
128
|
-
it('wires the skeleton: component,
|
|
124
|
+
it('wires the skeleton: component, query hook, picker, body', () => {
|
|
129
125
|
const source = emitView(makeContext());
|
|
130
126
|
expect(source).toContain('export function PostDetailView');
|
|
131
127
|
expect(source).toContain('usePostsQuery');
|
|
132
|
-
expect(source).toContain('
|
|
133
|
-
expect(source).toContain('
|
|
134
|
-
expect(source).toContain('onEdit(item)');
|
|
135
|
-
expect(source).toContain("confirm('Delete this post?')");
|
|
136
|
-
expect(source).toContain('(item as any).title ??');
|
|
128
|
+
expect(source).toContain('Select Post:');
|
|
129
|
+
expect(source).toContain('formatDisplayValue((item as any).title)');
|
|
137
130
|
expect(source).not.toMatch(/\{\{[A-Z_]+\}\}/);
|
|
131
|
+
// Detail is inspection-only — delete/edit live in the form view.
|
|
132
|
+
expect(source).not.toContain('useDeletePostMutation');
|
|
133
|
+
expect(source).not.toContain('onEdit(item)');
|
|
134
|
+
expect(source).not.toContain("confirm('Delete this post?')");
|
|
138
135
|
});
|
|
139
136
|
|
|
140
|
-
it('handles plural singular correctly for
|
|
137
|
+
it('handles plural / singular correctly for loading + empty copy', () => {
|
|
141
138
|
const source = emitView(makeContext());
|
|
142
139
|
expect(source).toContain('Loading…');
|
|
143
|
-
// SINGULAR_LOWER substitution: "No post
|
|
144
|
-
expect(source).toContain('No post
|
|
140
|
+
// SINGULAR_LOWER substitution: "No post to display."
|
|
141
|
+
expect(source).toContain('No post to display');
|
|
142
|
+
// PLURAL_LOWER substitution: "No posts yet" (picker empty state)
|
|
143
|
+
expect(source).toContain('No posts yet');
|
|
145
144
|
});
|
|
146
145
|
});
|
|
@@ -82,11 +82,11 @@ describe('composeListBody — direct output', () => {
|
|
|
82
82
|
expect(body).toContain('onClick={() => onSelect?.(item)}');
|
|
83
83
|
|
|
84
84
|
// Count how many times each field name appears in cell-context (inside
|
|
85
|
-
// the `<td>...item.FIELD
|
|
86
|
-
// business fields exactly once each.
|
|
85
|
+
// the `<td>...formatDisplayValue((item as any).FIELD)...` substring).
|
|
86
|
+
// Metadata should appear 0 times; business fields exactly once each.
|
|
87
87
|
const cellsRegion = body.slice(body.indexOf('<tbody'));
|
|
88
88
|
const cellAppearances = (field: string) =>
|
|
89
|
-
(cellsRegion.match(new RegExp(`item as any\\)\\.${field}
|
|
89
|
+
(cellsRegion.match(new RegExp(`formatDisplayValue\\(\\(item as any\\)\\.${field}\\)`, 'g')) ?? []).length;
|
|
90
90
|
|
|
91
91
|
expect(cellAppearances('title')).toBe(1);
|
|
92
92
|
expect(cellAppearances('body')).toBe(1);
|
package/libs/instance-factories/applications/templates/react-starter/__tests__/orchestrator.test.ts
CHANGED
|
@@ -88,6 +88,8 @@ describe('orchestrator — first-run (empty project)', () => {
|
|
|
88
88
|
`${HASHES_DIR}/${HASHES_FILE}`,
|
|
89
89
|
'package.json',
|
|
90
90
|
'src/App.tsx',
|
|
91
|
+
'src/hooks/useAppEvents.tsx',
|
|
92
|
+
'src/hooks/useResizableSidebar.ts',
|
|
91
93
|
'src/lib/entity-display.ts',
|
|
92
94
|
'src/views/PostDashboardView.tsx',
|
|
93
95
|
'src/views/PostDetailView.tsx',
|
|
@@ -151,7 +151,7 @@ describe('P3 — list view table shell parity', () => {
|
|
|
151
151
|
expect(body).toContain('overflow-x-auto');
|
|
152
152
|
expect(body).toContain('min-w-full');
|
|
153
153
|
expect(body).toContain('divide-y divide-gray-200');
|
|
154
|
-
expect(body).toContain('bg-gray-50 dark:bg-gray-
|
|
154
|
+
expect(body).toContain('bg-gray-50 dark:bg-gray-700');
|
|
155
155
|
});
|
|
156
156
|
|
|
157
157
|
it('rows are synthesized by Factory B (the adapter emits placeholder for react mount)', () => {
|
|
@@ -40,15 +40,16 @@ describe('app-tsx-generator', () => {
|
|
|
40
40
|
// One import per (model, view) pair — 2 models × 4 view types = 8 imports
|
|
41
41
|
const importLines = source.match(/^import \{ \w+View \} from '\.\/views\//gm) ?? [];
|
|
42
42
|
expect(importLines.length).toBe(8);
|
|
43
|
-
// Navigation wiring
|
|
44
|
-
|
|
45
|
-
expect(source).toContain(
|
|
43
|
+
// Navigation wiring — post-R0, setSelection is inlined (no select() helper).
|
|
44
|
+
// Selection state is a discriminated union with kind='model' for CRUD views.
|
|
45
|
+
expect(source).toContain(`setSelection({ kind: 'model', model: 'Post', view: 'list' })`);
|
|
46
|
+
expect(source).toContain(`setSelection({ kind: 'model', model: 'Author', view: 'dashboard' })`);
|
|
46
47
|
});
|
|
47
48
|
|
|
48
49
|
it('handles an empty spec gracefully', async () => {
|
|
49
50
|
const source = await generateAppTsx({ spec: { models: {}, views: {} } });
|
|
50
51
|
assertValidTsx(source, 'App.tsx (empty spec)');
|
|
51
|
-
expect(source).toContain('No models
|
|
52
|
+
expect(source).toContain('No models or operations');
|
|
52
53
|
// Still renders a QueryClientProvider
|
|
53
54
|
expect(source).toContain('QueryClientProvider');
|
|
54
55
|
});
|
|
@@ -58,8 +58,11 @@ describe('views-generator.generate', () => {
|
|
|
58
58
|
expect(paths).toContain('src/views/AuthorFormView.tsx');
|
|
59
59
|
expect(paths).toContain('src/views/AuthorDashboardView.tsx');
|
|
60
60
|
expect(paths).toContain('src/lib/entity-display.ts');
|
|
61
|
-
|
|
62
|
-
expect(paths
|
|
61
|
+
expect(paths).toContain('src/hooks/useResizableSidebar.ts');
|
|
62
|
+
expect(paths).toContain('src/hooks/useAppEvents.tsx');
|
|
63
|
+
// 2 models × 4 view types + 3 helpers (entity-display, useResizableSidebar,
|
|
64
|
+
// useAppEvents) = 11 files.
|
|
65
|
+
expect(paths.length).toBe(11);
|
|
63
66
|
});
|
|
64
67
|
|
|
65
68
|
it('every emitted view file parses as valid TSX', async () => {
|
|
@@ -133,7 +136,11 @@ describe('views-generator.generate', () => {
|
|
|
133
136
|
|
|
134
137
|
it('handles an empty models map gracefully', async () => {
|
|
135
138
|
const files = await generate({ spec: { models: {}, views: {} } });
|
|
136
|
-
// Only the
|
|
137
|
-
expect(Object.keys(files)).toEqual([
|
|
139
|
+
// Only the helper files are emitted; no view files.
|
|
140
|
+
expect(Object.keys(files).sort()).toEqual([
|
|
141
|
+
'src/hooks/useAppEvents.tsx',
|
|
142
|
+
'src/hooks/useResizableSidebar.ts',
|
|
143
|
+
'src/lib/entity-display.ts',
|
|
144
|
+
]);
|
|
138
145
|
});
|
|
139
146
|
});
|