@specverse/engines 4.2.0 → 4.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/assets/templates/default/specs/main.specly +65 -0
  2. package/dist/libs/instance-factories/CURVED-INTERFACE.md +278 -0
  3. package/dist/libs/instance-factories/README.md +73 -0
  4. package/dist/libs/instance-factories/applications/README.md +51 -0
  5. package/dist/libs/instance-factories/applications/generic-app.yaml +52 -0
  6. package/dist/libs/instance-factories/applications/react-app-runtime.yaml +139 -0
  7. package/dist/libs/instance-factories/applications/react-app-starter.yaml +143 -0
  8. package/dist/libs/instance-factories/applications/templates/react/env-example-generator.js +24 -2
  9. package/dist/libs/instance-factories/applications/templates/react/vite-config-generator.js +54 -33
  10. package/dist/libs/instance-factories/applications/templates/react-starter/README.md +211 -0
  11. package/dist/libs/instance-factories/applications/templates/react-starter/api-types-starter-generator.js +69 -0
  12. package/dist/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.js +1 -1
  13. package/dist/libs/instance-factories/applications/templates/react-starter/belongs-to.js +40 -0
  14. package/dist/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.js +11 -3
  15. package/dist/libs/instance-factories/applications/templates/react-starter/detail-body-composer.js +18 -16
  16. package/dist/libs/instance-factories/applications/templates/react-starter/form-body-composer.js +50 -23
  17. package/dist/libs/instance-factories/applications/templates/react-starter/helpers-emitter.js +9 -3
  18. package/dist/libs/instance-factories/applications/templates/react-starter/list-body-composer.js +17 -7
  19. package/dist/libs/instance-factories/applications/templates/react-starter/orchestrator.js +16 -5
  20. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/dashboard.tsx.template +49 -0
  21. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +96 -0
  22. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +116 -0
  23. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +74 -0
  24. package/dist/libs/instance-factories/applications/templates/react-starter/use-api-hooks-starter-generator.js +95 -0
  25. package/dist/libs/instance-factories/applications/templates/react-starter/view-emitter.js +26 -1
  26. package/dist/libs/instance-factories/archived/fastify-prisma.yaml +104 -0
  27. package/dist/libs/instance-factories/cli/README.md +43 -0
  28. package/dist/libs/instance-factories/cli/commander-js.yaml +55 -0
  29. package/dist/libs/instance-factories/cli/templates/commander/command-generator.js +49 -1
  30. package/dist/libs/instance-factories/communication/README.md +47 -0
  31. package/dist/libs/instance-factories/communication/event-emitter.yaml +60 -0
  32. package/dist/libs/instance-factories/communication/rabbitmq-events.yaml +87 -0
  33. package/dist/libs/instance-factories/controllers/README.md +42 -0
  34. package/dist/libs/instance-factories/controllers/fastify.yaml +139 -0
  35. package/dist/libs/instance-factories/controllers/templates/fastify/server-generator.js +29 -2
  36. package/dist/libs/instance-factories/infrastructure/README.md +29 -0
  37. package/dist/libs/instance-factories/infrastructure/docker-k8s.yaml +61 -0
  38. package/dist/libs/instance-factories/orms/README.md +54 -0
  39. package/dist/libs/instance-factories/orms/prisma.yaml +89 -0
  40. package/dist/libs/instance-factories/orms/templates/prisma/schema-generator.js +2 -2
  41. package/dist/libs/instance-factories/scaffolding/README.md +49 -0
  42. package/dist/libs/instance-factories/scaffolding/generic-scaffold.yaml +65 -0
  43. package/dist/libs/instance-factories/sdks/README.md +28 -0
  44. package/dist/libs/instance-factories/sdks/python-sdk.yaml +66 -0
  45. package/dist/libs/instance-factories/sdks/typescript-sdk.yaml +59 -0
  46. package/dist/libs/instance-factories/services/README.md +55 -0
  47. package/dist/libs/instance-factories/services/prisma-services.yaml +71 -0
  48. package/dist/libs/instance-factories/storage/README.md +34 -0
  49. package/dist/libs/instance-factories/storage/mongodb.yaml +79 -0
  50. package/dist/libs/instance-factories/storage/postgresql.yaml +75 -0
  51. package/dist/libs/instance-factories/storage/redis.yaml +79 -0
  52. package/dist/libs/instance-factories/testing/README.md +40 -0
  53. package/dist/libs/instance-factories/testing/vitest-tests.yaml +63 -0
  54. package/dist/libs/instance-factories/tools/README.md +70 -0
  55. package/dist/libs/instance-factories/tools/mcp.yaml +36 -0
  56. package/dist/libs/instance-factories/tools/vscode.yaml +35 -0
  57. package/dist/libs/instance-factories/validation/README.md +38 -0
  58. package/dist/libs/instance-factories/validation/zod.yaml +56 -0
  59. package/dist/realize/engines/code-generator.d.ts.map +1 -1
  60. package/dist/realize/engines/code-generator.js +3 -0
  61. package/dist/realize/engines/code-generator.js.map +1 -1
  62. package/libs/instance-factories/applications/react-app-starter.yaml +10 -17
  63. package/libs/instance-factories/applications/templates/react/env-example-generator.ts +24 -2
  64. package/libs/instance-factories/applications/templates/react/vite-config-generator.ts +54 -33
  65. package/libs/instance-factories/applications/templates/react-starter/__tests__/detail-body-composer.test.ts +5 -4
  66. package/libs/instance-factories/applications/templates/react-starter/__tests__/form-body-composer.test.ts +18 -5
  67. package/libs/instance-factories/applications/templates/react-starter/__tests__/orchestrator.test.ts +83 -62
  68. package/libs/instance-factories/applications/templates/react-starter/api-types-starter-generator.ts +98 -0
  69. package/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.ts +1 -1
  70. package/libs/instance-factories/applications/templates/react-starter/belongs-to.ts +82 -0
  71. package/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.ts +20 -5
  72. package/libs/instance-factories/applications/templates/react-starter/detail-body-composer.ts +33 -33
  73. package/libs/instance-factories/applications/templates/react-starter/form-body-composer.ts +107 -30
  74. package/libs/instance-factories/applications/templates/react-starter/helpers-emitter.ts +9 -3
  75. package/libs/instance-factories/applications/templates/react-starter/list-body-composer.ts +34 -8
  76. package/libs/instance-factories/applications/templates/react-starter/orchestrator.ts +41 -26
  77. package/libs/instance-factories/applications/templates/react-starter/skeletons/dashboard.tsx.template +2 -0
  78. package/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +2 -0
  79. package/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +2 -0
  80. package/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +2 -0
  81. package/libs/instance-factories/applications/templates/react-starter/use-api-hooks-starter-generator.ts +124 -0
  82. package/libs/instance-factories/applications/templates/react-starter/view-emitter.ts +58 -0
  83. package/libs/instance-factories/cli/templates/commander/command-generator.ts +49 -1
  84. package/libs/instance-factories/controllers/fastify.yaml +7 -0
  85. package/libs/instance-factories/controllers/templates/fastify/server-generator.ts +36 -2
  86. package/libs/instance-factories/orms/templates/prisma/schema-generator.ts +11 -4
  87. package/package.json +2 -1
@@ -21,6 +21,7 @@ import { createUniversalTailwindAdapter } from '@specverse/runtime/views/tailwin
21
21
  import { inferFieldsFromSchema } from '@specverse/runtime/views/core';
22
22
  import { htmlToJsx } from './html-to-jsx.js';
23
23
  import type { EmitContext, ModelSpec } from './view-emitter.js';
24
+ import { buildFKMap } from './belongs-to.js';
24
25
 
25
26
  /**
26
27
  * Sentinel token. Must be ASCII-safe (no curly braces) so it survives
@@ -34,8 +35,24 @@ const TBODY_SENTINEL = '__SPECVERSE_TBODY_ROWS__';
34
35
  * meant to be dropped at `{{BODY}}` inside `skeletons/list.tsx.template`.
35
36
  */
36
37
  export function composeListBody(context: EmitContext): string {
37
- const columns = inferColumns(context);
38
- const headers = columns.map(humanize);
38
+ const fkMap = buildFKMap(context.model);
39
+ // Inference (`inferFieldsFromSchema`) reads attributes and doesn't
40
+ // see belongsTo relationships. For list views we want a column per
41
+ // belongsTo so users can scan "who owns this" at a glance —
42
+ // otherwise a Task table hides its Project and Assignee columns.
43
+ // Append any FK columns the inference missed, preserving order.
44
+ const inferred = inferColumns(context);
45
+ const inferredSet = new Set(inferred);
46
+ const extraFKs = [...fkMap.keys()].filter(k => !inferredSet.has(k));
47
+ const columns = [...inferred, ...extraFKs];
48
+
49
+ // Header label for an FK column should use the relationship name
50
+ // ("Owner") not the raw column name ("Owner Id"). The cell rendering
51
+ // in buildRowMap applies the matching FK→name resolution.
52
+ const headers = columns.map(col => {
53
+ const fk = fkMap.get(col);
54
+ return humanize(fk ? fk.name : col);
55
+ });
39
56
 
40
57
  const adapter = createUniversalTailwindAdapter({ darkMode: true });
41
58
  const shellHtml = adapter.components.table.render({
@@ -52,7 +69,7 @@ export function composeListBody(context: EmitContext): string {
52
69
  // skeleton's useMemo produces `filtered: Model[]`, so the map
53
70
  // expression binds over that. onSelect is a prop the skeleton
54
71
  // declares. Delete action wires to the deleteItem mutation.
55
- const rowMap = buildRowMap(columns);
72
+ const rowMap = buildRowMap(columns, fkMap);
56
73
 
57
74
  if (!shellJsx.includes(TBODY_SENTINEL)) {
58
75
  // Defensive: catches adapter output changes that no longer flow
@@ -93,17 +110,26 @@ function humanize(name: string): string {
93
110
  * row. Keep the output readable so users inspecting the generated
94
111
  * file can follow what's happening.
95
112
  */
96
- function buildRowMap(columns: string[]): string {
113
+ function buildRowMap(columns: string[], fkMap: Map<string, { name: string; target: string }>): string {
97
114
  // Type-cast to `any` to avoid TS generic syntax (`<string, unknown>`)
98
115
  // inside JSX expressions, which the TSX parser can't always
99
116
  // disambiguate from a JSX opening tag. `any` is the right choice for
100
117
  // starter-kit output anyway — the user will often reshape the row
101
118
  // type after editing.
119
+ //
120
+ // FK columns render through `resolveEntityDisplayName(id, options)`
121
+ // so the user sees a name instead of a UUID. The options array
122
+ // (`${relName}Options`) is populated by the hook call wired into
123
+ // the list skeleton via view-emitter's RELATED_HOOKS substitution.
102
124
  const cells = columns
103
- .map(col =>
104
- ` <td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300">` +
105
- `{String((item as any).${col} ?? '')}</td>`
106
- )
125
+ .map(col => {
126
+ const fk = fkMap.get(col);
127
+ const expr = fk
128
+ ? `{resolveEntityDisplayName((item as any).${col}, ${fk.name}Options)}`
129
+ : `{String((item as any).${col} ?? '')}`;
130
+ return ` <td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300">` +
131
+ `${expr}</td>`;
132
+ })
107
133
  .join('\n');
108
134
 
109
135
  return [
@@ -13,6 +13,8 @@
13
13
  * so it rolls through the same write pipeline.
14
14
  */
15
15
 
16
+ import { existsSync, mkdirSync, writeFileSync } from 'fs';
17
+ import { join, dirname } from 'path';
16
18
  import { generate as generateViews } from './views-generator.js';
17
19
  import { generate as generateAppTsx } from './app-tsx-generator.js';
18
20
  import { generate as generatePackageJson } from './package-json-generator.js';
@@ -25,32 +27,35 @@ import {
25
27
  } from './regen-safety.js';
26
28
  import type { ExpandedSpec } from './views-generator.js';
27
29
 
30
+ /**
31
+ * Context shape as it arrives from realize's code-generator. The
32
+ * generator protocol is "function receives context, returns string
33
+ * (the single file contents)". This orchestrator is special: instead
34
+ * of one file, it produces many (views × models, App.tsx,
35
+ * package.json, regen-safety manifest). It writes them directly and
36
+ * returns '' so realize's write pipeline skips the no-op.
37
+ *
38
+ * `outputDir` and `frontendDir` come from the realize-time context.
39
+ * `frontendDir` may be '.' for standalone layouts or 'frontend' for
40
+ * monorepos — either way, the resolved write root is
41
+ * `outputDir + frontendDir`.
42
+ */
28
43
  export interface OrchestratorContext {
29
- /** Expanded spec (post-inference). */
30
44
  spec: ExpandedSpec & { metadata?: { name?: string }; name?: string };
31
- /** Resolved manifest configuration. */
45
+ outputDir: string;
46
+ frontendDir?: string;
32
47
  manifest?: unknown;
33
- /** Absolute path to the project root (the frontend directory for this factory). */
34
- projectRoot: string;
35
48
  }
36
49
 
37
50
  /**
38
- * The shape realize expects from a multi-file generator: a map of
39
- * relative paths to their contents. Realize creates parent dirs and
40
- * writes the files verbatim.
41
- *
42
- * Skipped (user-edited) files are intentionally absent — realize
43
- * never sees them.
51
+ * Produce the complete file set for a Factory B run, writing each
52
+ * file directly. Returns '' so the caller's single-file writeOutput
53
+ * short-circuits.
44
54
  */
45
- export type OrchestratorOutput = Record<string, string>;
46
-
47
- /**
48
- * Produce the complete file set for a Factory B run. Realize writes
49
- * whatever comes back; the orchestrator is the sole source of truth
50
- * for "what ends up on disk."
51
- */
52
- export async function generate(context: OrchestratorContext): Promise<OrchestratorOutput> {
53
- const { spec, projectRoot } = context;
55
+ export async function generate(context: OrchestratorContext): Promise<string> {
56
+ const { spec, outputDir } = context;
57
+ const frontendDir = context.frontendDir || 'frontend';
58
+ const projectRoot = frontendDir === '.' ? outputDir : join(outputDir, frontendDir);
54
59
 
55
60
  // 1. Produce all file contents (pure composition — no I/O yet).
56
61
  const proposed: Record<string, string> = {};
@@ -61,20 +66,30 @@ export async function generate(context: OrchestratorContext): Promise<Orchestrat
61
66
  proposed['src/App.tsx'] = await generateAppTsx({ spec });
62
67
  proposed['package.json'] = await generatePackageJson({ spec });
63
68
 
64
- // 2. Load prior hash manifest + triage. Read-only.
69
+ // 2. Content-hashing triage against the prior manifest. Read-only.
65
70
  const prevManifest = loadHashManifest(projectRoot);
66
71
  const result = reconcileWrites(projectRoot, proposed, prevManifest);
67
72
 
68
73
  // 3. Log the summary for operator visibility.
69
74
  console.log(summarize(result, projectRoot));
70
75
 
71
- // 4. Emit the hash manifest as one of the written files so it flows
72
- // through realize's normal write pipeline.
73
- const manifestPath = `${HASHES_DIR}/${HASHES_FILE}`;
74
- const out: OrchestratorOutput = {
76
+ // 4. Write the approved files + the updated hash manifest.
77
+ const toWrite: Record<string, string> = {
75
78
  ...result.approvedWrites,
76
- [manifestPath]: JSON.stringify(result.manifest, null, 2) + '\n',
79
+ [`${HASHES_DIR}/${HASHES_FILE}`]: JSON.stringify(result.manifest, null, 2) + '\n',
77
80
  };
78
81
 
79
- return out;
82
+ for (const [rel, content] of Object.entries(toWrite)) {
83
+ const abs = join(projectRoot, rel);
84
+ const dir = dirname(abs);
85
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
86
+ writeFileSync(abs, content, 'utf-8');
87
+ }
88
+
89
+ // Realize's single-file writeOutput skips empty strings (see
90
+ // realize/index.ts writeOutput). The orchestrator already wrote
91
+ // everything it needed.
92
+ return '';
80
93
  }
94
+
95
+ export default generate;
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { useMemo } from 'react';
14
14
  import { use{{PLURAL_MODEL}}Query } from '../hooks/useApi';
15
+ {{RELATED_IMPORTS}}
15
16
  import type { {{MODEL_NAME}} } from '../types/api';
16
17
 
17
18
  interface {{MODEL_NAME}}DashboardViewProps {
@@ -25,6 +26,7 @@ export function {{MODEL_NAME}}DashboardView({
25
26
  onSelect,
26
27
  }: {{MODEL_NAME}}DashboardViewProps) {
27
28
  const { data: items = [], isLoading, error } = use{{PLURAL_MODEL}}Query();
29
+ {{RELATED_HOOKS}}
28
30
 
29
31
  const preview = useMemo(
30
32
  () => items.slice(0, previewLimit),
@@ -6,6 +6,7 @@
6
6
  * regeneration of this file, delete it first, then run `spv realize`.
7
7
  */
8
8
  import { use{{PLURAL_MODEL}}Query, useDelete{{MODEL_NAME}}Mutation } from '../hooks/useApi';
9
+ {{RELATED_IMPORTS}}
9
10
  import type { {{MODEL_NAME}} } from '../types/api';
10
11
 
11
12
  interface {{MODEL_NAME}}DetailViewProps {
@@ -24,6 +25,7 @@ export function {{MODEL_NAME}}DetailView({
24
25
  }: {{MODEL_NAME}}DetailViewProps) {
25
26
  const { data: items = [], isLoading, error } = use{{PLURAL_MODEL}}Query();
26
27
  const deleteItem = useDelete{{MODEL_NAME}}Mutation();
28
+ {{RELATED_HOOKS}}
27
29
 
28
30
  const item = items.find(
29
31
  (x: {{MODEL_NAME}}) => (x as any).id === entityId
@@ -14,6 +14,7 @@ import {
14
14
  useCreate{{MODEL_NAME}}Mutation,
15
15
  useUpdate{{MODEL_NAME}}Mutation,
16
16
  } from '../hooks/useApi';
17
+ {{RELATED_IMPORTS}}
17
18
  import type { {{MODEL_NAME}} } from '../types/api';
18
19
 
19
20
  type FormMode = 'create' | 'update';
@@ -35,6 +36,7 @@ export function {{MODEL_NAME}}FormView({
35
36
  const { data: items = [] } = use{{PLURAL_MODEL}}Query();
36
37
  const createItem = useCreate{{MODEL_NAME}}Mutation();
37
38
  const updateItem = useUpdate{{MODEL_NAME}}Mutation();
39
+ {{RELATED_HOOKS}}
38
40
 
39
41
  const existing =
40
42
  mode === 'update'
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { useState, useMemo } from 'react';
9
9
  import { use{{PLURAL_MODEL}}Query, useDelete{{MODEL_NAME}}Mutation } from '../hooks/useApi';
10
+ {{RELATED_IMPORTS}}
10
11
  import type { {{MODEL_NAME}} } from '../types/api';
11
12
 
12
13
  interface {{MODEL_NAME}}ListViewProps {
@@ -17,6 +18,7 @@ interface {{MODEL_NAME}}ListViewProps {
17
18
  export function {{MODEL_NAME}}ListView({ onSelect, onCreate }: {{MODEL_NAME}}ListViewProps) {
18
19
  const { data: items = [], isLoading, error } = use{{PLURAL_MODEL}}Query();
19
20
  const deleteItem = useDelete{{MODEL_NAME}}Mutation();
21
+ {{RELATED_HOOKS}}
20
22
  const [searchTerm, setSearchTerm] = useState('');
21
23
 
22
24
  const filtered = useMemo(
@@ -0,0 +1,124 @@
1
+ /**
2
+ * useApi hooks generator for ReactAppStarter
3
+ *
4
+ * Emits typed per-model React Query hooks that the starter's view
5
+ * skeletons import directly (`useTasksQuery`, `useCreateTaskMutation`,
6
+ * etc.). All hooks hit the realized backend's REST endpoints under
7
+ * `/api/{resource}` via the sibling `apiClient` helper.
8
+ *
9
+ * Why a separate generator from the runtime one: ReactAppRuntime
10
+ * ships a generic `useEntitiesQuery(controller, model)` because its
11
+ * views are rendered by @specverse/runtime using runtime dispatch.
12
+ * The starter wants plain typed hooks the user can read and edit.
13
+ */
14
+
15
+ /**
16
+ * Minimal English pluralizer. Matches the conventions used by
17
+ * view-emitter so the generated hooks pair cleanly with the generated
18
+ * view imports.
19
+ */
20
+ function pluralize(s: string): string {
21
+ if (/y$/.test(s) && !/[aeiou]y$/.test(s)) return s.replace(/y$/, 'ies');
22
+ if (/(s|x|z|ch|sh)$/.test(s)) return s + 'es';
23
+ return s + 's';
24
+ }
25
+
26
+ export interface UseApiHooksStarterContext {
27
+ spec: {
28
+ models?: Record<string, any>;
29
+ };
30
+ manifest?: unknown;
31
+ }
32
+
33
+ export async function generate(context: UseApiHooksStarterContext): Promise<string> {
34
+ const models = Object.keys(context.spec.models ?? {});
35
+
36
+ const importsAndTypes = `/**
37
+ * useApi — typed per-model React Query hooks (ReactAppStarter)
38
+ *
39
+ * Safe to edit. Users who want to reshape their API client or add
40
+ * request/response interceptors should start here. Each hook calls
41
+ * the sibling \`apiClient\` which does the actual fetch; edit
42
+ * \`apiClient.ts\` to change transport concerns (headers, auth, etc.).
43
+ */
44
+
45
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
46
+ ${models.map(m => `import type { ${m} } from '../types/api';`).join('\n')}
47
+
48
+ const API_BASE = import.meta.env.VITE_API_BASE_URL || '';
49
+
50
+ 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
+ });
55
+ if (!res.ok) {
56
+ throw new Error(\`\${res.status} \${res.statusText} — \${url}\`);
57
+ }
58
+ if (res.status === 204) return undefined as T;
59
+ return (await res.json()) as T;
60
+ }
61
+ `;
62
+
63
+ const hookBlocks = models.map(m => generateModelHooks(m)).join('\n\n');
64
+
65
+ return importsAndTypes + '\n' + hookBlocks + '\n';
66
+ }
67
+
68
+ function generateModelHooks(model: string): string {
69
+ const plural = pluralize(model);
70
+ const resource = plural.toLowerCase();
71
+ const modelLower = model.toLowerCase();
72
+
73
+ return `// ─── ${model} ───────────────────────────────────────────────────
74
+
75
+ export function use${plural}Query() {
76
+ return useQuery({
77
+ queryKey: ['${resource}'],
78
+ queryFn: () => fetchJSON<${model}[]>(\`\${API_BASE}/api/${resource}\`),
79
+ });
80
+ }
81
+
82
+ export function use${model}Query(id: string | number | undefined) {
83
+ return useQuery({
84
+ queryKey: ['${resource}', id],
85
+ queryFn: () => fetchJSON<${model}>(\`\${API_BASE}/api/${resource}/\${id}\`),
86
+ enabled: id !== undefined && id !== null,
87
+ });
88
+ }
89
+
90
+ export function useCreate${model}Mutation() {
91
+ const qc = useQueryClient();
92
+ return useMutation({
93
+ mutationFn: (data: Partial<${model}>) =>
94
+ fetchJSON<${model}>(\`\${API_BASE}/api/${resource}\`, {
95
+ method: 'POST',
96
+ body: JSON.stringify(data),
97
+ }),
98
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['${resource}'] }),
99
+ });
100
+ }
101
+
102
+ export function useUpdate${model}Mutation() {
103
+ const qc = useQueryClient();
104
+ return useMutation({
105
+ mutationFn: ({ id, data }: { id: string | number; data: Partial<${model}> }) =>
106
+ fetchJSON<${model}>(\`\${API_BASE}/api/${resource}/\${id}\`, {
107
+ method: 'PATCH',
108
+ body: JSON.stringify(data),
109
+ }),
110
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['${resource}'] }),
111
+ });
112
+ }
113
+
114
+ export function useDelete${model}Mutation() {
115
+ const qc = useQueryClient();
116
+ return useMutation({
117
+ mutationFn: (id: string | number) =>
118
+ fetchJSON<void>(\`\${API_BASE}/api/${resource}/\${id}\`, { method: 'DELETE' }),
119
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['${resource}'] }),
120
+ });
121
+ }`;
122
+ }
123
+
124
+ export default generate;
@@ -16,6 +16,7 @@
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
20
 
20
21
  /**
21
22
  * The minimal shape of a view spec this emitter needs. Matches the
@@ -113,17 +114,74 @@ interface Substitutions {
113
114
  PLURAL_LOWER: string;
114
115
  SINGULAR_LOWER: string;
115
116
  BODY: string;
117
+ /**
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.
121
+ */
122
+ RELATED_IMPORTS: string;
123
+ /**
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
+ */
128
+ RELATED_HOOKS: string;
116
129
  }
117
130
 
118
131
  function buildSubstitutions(context: EmitContext, body: string): Substitutions {
119
132
  const modelName = context.model.name;
120
133
  const pluralModel = pluralize(modelName);
134
+ const { imports, hooks } = buildBelongsToWiring(context);
121
135
  return {
122
136
  MODEL_NAME: modelName,
123
137
  PLURAL_MODEL: pluralModel,
124
138
  PLURAL_LOWER: pluralModel.toLowerCase(),
125
139
  SINGULAR_LOWER: modelName.toLowerCase(),
126
140
  BODY: body,
141
+ RELATED_IMPORTS: imports,
142
+ RELATED_HOOKS: hooks,
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Compute the belongsTo wiring for a view: one import line per unique
148
+ * target hook, one hook call per relationship (even if two relations
149
+ * share a target — each needs its own options variable).
150
+ *
151
+ * The entity-display helper import set is view-type-dependent to
152
+ * avoid unused-import TS errors:
153
+ * - form uses `getEntityDisplayName(opt)` (has whole entity in hand)
154
+ * - list / detail / dashboard use `resolveEntityDisplayName(id, list)`
155
+ * (has FK id, looks up in the list)
156
+ */
157
+ function buildBelongsToWiring(context: EmitContext): { imports: string; hooks: string } {
158
+ const rels = extractBelongsToTargets(context.model);
159
+ if (rels.length === 0) return { imports: '', hooks: '' };
160
+
161
+ // Dedupe hook imports by target — two belongsTo to the same model
162
+ // share the same query hook.
163
+ const hookImportNames = new Set<string>();
164
+ for (const rel of rels) {
165
+ hookImportNames.add(`use${pluralizeShared(rel.target)}Query`);
166
+ }
167
+
168
+ const helperImport = context.view.type.toLowerCase() === 'form'
169
+ ? 'getEntityDisplayName'
170
+ : 'resolveEntityDisplayName';
171
+
172
+ const importLines: string[] = [];
173
+ importLines.push(
174
+ `import { ${[...hookImportNames].join(', ')} } from '../hooks/useApi';`
175
+ );
176
+ importLines.push(`import { ${helperImport} } from '../lib/entity-display';`);
177
+
178
+ const hookLines = rels.map(rel =>
179
+ ` const { data: ${rel.name}Options = [] } = use${pluralizeShared(rel.target)}Query();`
180
+ );
181
+
182
+ return {
183
+ imports: importLines.join('\n'),
184
+ hooks: hookLines.join('\n'),
127
185
  };
128
186
  }
129
187
 
@@ -560,8 +560,56 @@ import { fileURLToPath } from 'url';`,
560
560
  }
561
561
 
562
562
  copyDir(templateDir, destDir);
563
+
564
+ // If the copied template didn't ship a specs/main.specly, load
565
+ // the canonical default spec from @specverse/engines/assets —
566
+ // same file app-demo's Server Manager "new spec" action uses,
567
+ // so both entry points start a user on the same footing. The
568
+ // template can opt out of this by shipping its own spec.
569
+ const specDestPath = join(destDir, 'specs', 'main.specly');
570
+ if (!existsSync(specDestPath)) {
571
+ try {
572
+ const { createRequire } = await import('module');
573
+ const require = createRequire(import.meta.url);
574
+ const enginesPkg = require.resolve('@specverse/engines/package.json');
575
+ const canonicalSpec = join(
576
+ dirname(enginesPkg),
577
+ 'assets', 'templates', 'default', 'specs', 'main.specly'
578
+ );
579
+ if (existsSync(canonicalSpec)) {
580
+ let content = readFileSync(canonicalSpec, 'utf8');
581
+ for (const [key, val] of Object.entries(vars)) {
582
+ content = content.split(key).join(val);
583
+ }
584
+ mkdirSync(join(destDir, 'specs'), { recursive: true });
585
+ writeFileSync(specDestPath, content);
586
+ }
587
+ } catch { /* engines not installed or template missing — proceed */ }
588
+ }
589
+
590
+ // --static: flip any ReactAppRuntime mappings in the copied
591
+ // manifests to ReactAppStarter so the user gets standalone-starter
592
+ // output on first \`spv realize\` without having to re-pass the
593
+ // flag. Same semantic as \`spv realize --static\` — just applied
594
+ // at init time. Idempotent; no-op for templates without a frontend.
595
+ if (options.static) {
596
+ const manifestsDir = join(destDir, 'manifests');
597
+ if (existsSync(manifestsDir)) {
598
+ for (const f of readdirSync(manifestsDir)) {
599
+ if (!f.endsWith('.yaml') && !f.endsWith('.yml')) continue;
600
+ const p = join(manifestsDir, f);
601
+ const before = readFileSync(p, 'utf8');
602
+ const after = before.replace(
603
+ /instanceFactory:\\s*["']?ReactAppRuntime["']?/g,
604
+ 'instanceFactory: "ReactAppStarter"'
605
+ );
606
+ if (after !== before) writeFileSync(p, after, 'utf8');
607
+ }
608
+ }
609
+ }
610
+
563
611
  console.log('Project created: ' + destDir);
564
- console.log('Template: ' + templateName);
612
+ console.log('Template: ' + templateName + (options.static ? ' (static / ReactAppStarter)' : ''));
565
613
  console.log('');
566
614
 
567
615
  // Build the "Next steps" hint from the actual scripts the
@@ -37,6 +37,12 @@ dependencies:
37
37
  version: "^11.1.0"
38
38
  - name: "@fastify/rate-limit"
39
39
  version: "^9.0.0"
40
+ # .env loading — main.ts calls `import 'dotenv/config'` first so
41
+ # PORT and other env vars populate before any module reads them.
42
+ # Mirrors vite's loadEnv on the frontend side, so `npm run
43
+ # dev:backend` honours the project's .env without a set -a dance.
44
+ - name: "dotenv"
45
+ version: "^16.4.0"
40
46
 
41
47
  dev:
42
48
  - name: "@types/node"
@@ -99,6 +105,7 @@ requirements:
99
105
  "fastify": "^5.8.3"
100
106
  "@fastify/cors": "^10.0.0"
101
107
  "yaml": "^2.3.0"
108
+ "dotenv": "^16.4.0"
102
109
  devDependencies:
103
110
  "tsx": "^4.0.0"
104
111
  scripts:
@@ -49,6 +49,27 @@ export default function generateFastifyServer(context: TemplateContext): string
49
49
  * Generated from SpecVerse specification
50
50
  */
51
51
 
52
+ // Load .env from the closest ancestor that has one. Walks up from
53
+ // this file's directory so the backend picks up the project-root
54
+ // .env whether it's running in the monorepo layout
55
+ // (generated/code/backend/src/main.ts with .env at generated/code/)
56
+ // or the standalone layout (generated/code/src/main.ts with .env at
57
+ // generated/code/). dotenv's default cwd-based behaviour doesn't
58
+ // handle the monorepo case because npm workspaces \`cd\`s into the
59
+ // workspace before running the script.
60
+ import { config as loadEnv } from 'dotenv';
61
+ import { existsSync } from 'fs';
62
+ import { resolve as resolvePath, dirname, join } from 'path';
63
+ import { fileURLToPath } from 'url';
64
+ {
65
+ let dir = dirname(fileURLToPath(import.meta.url));
66
+ while (dir !== '/' && dir !== dirname(dir)) {
67
+ const candidate = join(dir, '.env');
68
+ if (existsSync(candidate)) { loadEnv({ path: candidate }); break; }
69
+ dir = dirname(dir);
70
+ }
71
+ }
72
+
52
73
  import Fastify from 'fastify';
53
74
  import cors from '@fastify/cors';
54
75
  import { PrismaClient } from '@prisma/client';
@@ -112,9 +133,9 @@ ${hasEvents ? ` // Register WebSocket bridge for real-time frontend events
112
133
  // fall back to 127.0.0.1 cleanly. Binding :: fixes both cases.
113
134
  await fastify.listen({ port, host: '::' });
114
135
  console.log(\`Server running at http://localhost:\${port}\`);
115
- console.log(\`API endpoints: ${modelNames.map((n: string) => `/api/${n.toLowerCase()}s`).join(', ')}\`);
136
+ console.log(\`API endpoints: ${modelNames.map((n: string) => `/api/${pluralizeLower(n)}`).join(', ')}\`);
116
137
  ${hasEvents ? ` console.log(\`WebSocket: ws://localhost:\${port}/ws\`);
117
- console.log(\`Events: ${specEvents.join(', ')}\`);` : ''}
138
+ console.log(\`Events: ${specEvents.length} wired (GET /api/runtime/events for the full list)\`);` : ''}
118
139
  } catch (err) {
119
140
  fastify.log.error(err);
120
141
  process.exit(1);
@@ -124,3 +145,16 @@ ${hasEvents ? ` console.log(\`WebSocket: ws://localhost:\${port}/ws\`);
124
145
  start();
125
146
  `;
126
147
  }
148
+
149
+ /**
150
+ * Minimal English pluralizer — matches the same rules the routes
151
+ * generator uses so the startup banner's route list agrees with the
152
+ * actual routes the server exposes. "Category" → "categories", not
153
+ * "categorys"; "Box" → "boxes"; "Item" → "items".
154
+ */
155
+ function pluralizeLower(s: string): string {
156
+ const lower = s.toLowerCase();
157
+ if (/[^aeiou]y$/.test(lower)) return lower.slice(0, -1) + 'ies';
158
+ if (/(s|x|z|ch|sh)$/.test(lower)) return lower + 'es';
159
+ return lower + 's';
160
+ }
@@ -168,9 +168,11 @@ function buildMissingBackRefs(
168
168
  r.target === target && (r.type === 'hasMany' || r.type === 'hasOne')
169
169
  );
170
170
  const needsFieldInFk = parentRelsToSameTarget.length > 1;
171
+ // Back-ref FK column — same camelCase convention as the forward
172
+ // belongsTo path above (see line ~455).
171
173
  const fkSuffix = needsFieldInFk
172
- ? camelToSnake(fieldName) + '_id'
173
- : camelToSnake(model.name.charAt(0).toLowerCase() + model.name.slice(1)) + '_id';
174
+ ? fieldName + 'Id'
175
+ : model.name.charAt(0).toLowerCase() + model.name.slice(1) + 'Id';
174
176
  const fkName = fkSuffix;
175
177
  const fkPadding = ' '.repeat(Math.max(1, 15 - fkName.length));
176
178
  const refFieldName = needsFieldInFk
@@ -451,8 +453,13 @@ function generateRelationship(rel: any, model: any, relationMap: Map<string, str
451
453
 
452
454
  switch (rel.type) {
453
455
  case 'belongsTo':
454
- // Foreign key field — use snake_case for the FK column name
455
- const fkBase = rel.foreignKey || camelToSnake(name) + '_id';
456
+ // Foreign key field — use camelCase, matching the casing of
457
+ // every other attribute the generator emits. Earlier revisions
458
+ // used snake_case here, which inconsistently mixed
459
+ // `createdAt` (camel) with `category_id` (snake) in the same
460
+ // schema and leaked through to API responses, breaking
461
+ // camelCase FK lookups on the frontend.
462
+ const fkBase = rel.foreignKey || name + 'Id';
456
463
  const fkPadding = ' '.repeat(Math.max(1, 15 - fkBase.length));
457
464
  // Add @unique if the parent has a hasOne relation pointing to this model
458
465
  const isUniqueFK = hasOneTargets.has(`${rel.target}->${model.name}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specverse/engines",
3
- "version": "4.2.0",
3
+ "version": "4.2.2",
4
4
  "description": "SpecVerse toolchain — parser, inference, realize, generators, AI, registry",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -44,6 +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
48
  "ajv": "^8.17.0",
48
49
  "ajv-formats": "^2.1.0",
49
50
  "glob": "^10.0.0",