@specverse/engines 4.2.0 → 4.2.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/assets/templates/default/specs/main.specly +65 -0
- package/dist/libs/instance-factories/CURVED-INTERFACE.md +278 -0
- package/dist/libs/instance-factories/README.md +73 -0
- package/dist/libs/instance-factories/applications/README.md +51 -0
- package/dist/libs/instance-factories/applications/generic-app.yaml +52 -0
- package/dist/libs/instance-factories/applications/react-app-runtime.yaml +139 -0
- package/dist/libs/instance-factories/applications/react-app-starter.yaml +143 -0
- package/dist/libs/instance-factories/applications/templates/react/env-example-generator.js +24 -2
- package/dist/libs/instance-factories/applications/templates/react/vite-config-generator.js +54 -33
- package/dist/libs/instance-factories/applications/templates/react-starter/README.md +211 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/api-types-starter-generator.js +69 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.js +1 -1
- package/dist/libs/instance-factories/applications/templates/react-starter/belongs-to.js +40 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.js +11 -3
- package/dist/libs/instance-factories/applications/templates/react-starter/detail-body-composer.js +18 -16
- package/dist/libs/instance-factories/applications/templates/react-starter/form-body-composer.js +50 -23
- package/dist/libs/instance-factories/applications/templates/react-starter/helpers-emitter.js +9 -3
- package/dist/libs/instance-factories/applications/templates/react-starter/list-body-composer.js +17 -7
- package/dist/libs/instance-factories/applications/templates/react-starter/orchestrator.js +16 -5
- package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/dashboard.tsx.template +49 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +96 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +116 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +74 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/use-api-hooks-starter-generator.js +95 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/view-emitter.js +26 -1
- package/dist/libs/instance-factories/archived/fastify-prisma.yaml +104 -0
- package/dist/libs/instance-factories/cli/README.md +43 -0
- package/dist/libs/instance-factories/cli/commander-js.yaml +55 -0
- package/dist/libs/instance-factories/cli/templates/commander/command-generator.js +49 -1
- package/dist/libs/instance-factories/communication/README.md +47 -0
- package/dist/libs/instance-factories/communication/event-emitter.yaml +60 -0
- package/dist/libs/instance-factories/communication/rabbitmq-events.yaml +87 -0
- package/dist/libs/instance-factories/controllers/README.md +42 -0
- package/dist/libs/instance-factories/controllers/fastify.yaml +139 -0
- package/dist/libs/instance-factories/controllers/templates/fastify/server-generator.js +29 -2
- package/dist/libs/instance-factories/infrastructure/README.md +29 -0
- package/dist/libs/instance-factories/infrastructure/docker-k8s.yaml +61 -0
- package/dist/libs/instance-factories/orms/README.md +54 -0
- package/dist/libs/instance-factories/orms/prisma.yaml +89 -0
- package/dist/libs/instance-factories/orms/templates/prisma/schema-generator.js +2 -2
- package/dist/libs/instance-factories/scaffolding/README.md +49 -0
- package/dist/libs/instance-factories/scaffolding/generic-scaffold.yaml +65 -0
- package/dist/libs/instance-factories/sdks/README.md +28 -0
- package/dist/libs/instance-factories/sdks/python-sdk.yaml +66 -0
- package/dist/libs/instance-factories/sdks/typescript-sdk.yaml +59 -0
- package/dist/libs/instance-factories/services/README.md +55 -0
- package/dist/libs/instance-factories/services/prisma-services.yaml +71 -0
- package/dist/libs/instance-factories/storage/README.md +34 -0
- package/dist/libs/instance-factories/storage/mongodb.yaml +79 -0
- package/dist/libs/instance-factories/storage/postgresql.yaml +75 -0
- package/dist/libs/instance-factories/storage/redis.yaml +79 -0
- package/dist/libs/instance-factories/testing/README.md +40 -0
- package/dist/libs/instance-factories/testing/vitest-tests.yaml +63 -0
- package/dist/libs/instance-factories/tools/README.md +70 -0
- package/dist/libs/instance-factories/tools/mcp.yaml +36 -0
- package/dist/libs/instance-factories/tools/vscode.yaml +35 -0
- package/dist/libs/instance-factories/validation/README.md +38 -0
- package/dist/libs/instance-factories/validation/zod.yaml +56 -0
- package/dist/realize/engines/code-generator.d.ts.map +1 -1
- package/dist/realize/engines/code-generator.js +3 -0
- package/dist/realize/engines/code-generator.js.map +1 -1
- package/libs/instance-factories/applications/react-app-starter.yaml +10 -17
- package/libs/instance-factories/applications/templates/react/env-example-generator.ts +24 -2
- package/libs/instance-factories/applications/templates/react/vite-config-generator.ts +54 -33
- package/libs/instance-factories/applications/templates/react-starter/__tests__/detail-body-composer.test.ts +5 -4
- package/libs/instance-factories/applications/templates/react-starter/__tests__/form-body-composer.test.ts +18 -5
- package/libs/instance-factories/applications/templates/react-starter/__tests__/orchestrator.test.ts +83 -62
- package/libs/instance-factories/applications/templates/react-starter/api-types-starter-generator.ts +98 -0
- package/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.ts +1 -1
- package/libs/instance-factories/applications/templates/react-starter/belongs-to.ts +82 -0
- package/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.ts +20 -5
- package/libs/instance-factories/applications/templates/react-starter/detail-body-composer.ts +33 -33
- package/libs/instance-factories/applications/templates/react-starter/form-body-composer.ts +107 -30
- package/libs/instance-factories/applications/templates/react-starter/helpers-emitter.ts +9 -3
- package/libs/instance-factories/applications/templates/react-starter/list-body-composer.ts +34 -8
- package/libs/instance-factories/applications/templates/react-starter/orchestrator.ts +41 -26
- package/libs/instance-factories/applications/templates/react-starter/skeletons/dashboard.tsx.template +2 -0
- package/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +2 -0
- package/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +2 -0
- package/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +2 -0
- package/libs/instance-factories/applications/templates/react-starter/use-api-hooks-starter-generator.ts +124 -0
- package/libs/instance-factories/applications/templates/react-starter/view-emitter.ts +58 -0
- package/libs/instance-factories/cli/templates/commander/command-generator.ts +49 -1
- package/libs/instance-factories/controllers/fastify.yaml +7 -0
- package/libs/instance-factories/controllers/templates/fastify/server-generator.ts +36 -2
- package/libs/instance-factories/orms/templates/prisma/schema-generator.ts +11 -4
- package/package.json +1 -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
|
|
38
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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.
|
|
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.
|
|
72
|
-
|
|
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
|
-
[
|
|
79
|
+
[`${HASHES_DIR}/${HASHES_FILE}`]: JSON.stringify(result.manifest, null, 2) + '\n',
|
|
77
80
|
};
|
|
78
81
|
|
|
79
|
-
|
|
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),
|
package/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template
CHANGED
|
@@ -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
|
package/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template
CHANGED
|
@@ -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'
|
package/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
?
|
|
173
|
-
:
|
|
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
|
|
455
|
-
|
|
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}`);
|