@specverse/engines 4.1.21 → 4.1.22
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/libs/instance-factories/applications/templates/react/api-client-generator.js +32 -0
- package/dist/libs/instance-factories/applications/templates/react/runtime-app-tsx-generator.js +41 -4
- package/dist/libs/instance-factories/applications/templates/react/use-api-hooks-generator.js +27 -7
- package/libs/instance-factories/applications/templates/react/api-client-generator.ts +32 -0
- package/libs/instance-factories/applications/templates/react/runtime-app-tsx-generator.ts +41 -4
- package/libs/instance-factories/applications/templates/react/use-api-hooks-generator.ts +27 -7
- package/package.json +1 -1
|
@@ -304,6 +304,22 @@ export async function executeOperation(
|
|
|
304
304
|
return apiRequest<ApiResponse>(method, path, Object.keys(params).length > 0 ? params : null);
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
+
/**
|
|
308
|
+
* Execute a service operation
|
|
309
|
+
* Services are RPC-style under /services/{serviceName}/{operationName}
|
|
310
|
+
*/
|
|
311
|
+
export async function executeServiceOperation(
|
|
312
|
+
serviceName: string,
|
|
313
|
+
operationName: string,
|
|
314
|
+
params: Record<string, any>
|
|
315
|
+
): Promise<ApiResponse> {
|
|
316
|
+
return apiRequest<ApiResponse>(
|
|
317
|
+
'POST',
|
|
318
|
+
\`/services/\${serviceName}/\${operationName}\`,
|
|
319
|
+
params
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
307
323
|
/**
|
|
308
324
|
* Transition entity lifecycle state
|
|
309
325
|
*/
|
|
@@ -395,6 +411,22 @@ export async function executeOperation(
|
|
|
395
411
|
);
|
|
396
412
|
}
|
|
397
413
|
|
|
414
|
+
/**
|
|
415
|
+
* Execute a service operation
|
|
416
|
+
* Services are RPC-style under /services/{serviceName}/{operationName}
|
|
417
|
+
*/
|
|
418
|
+
export async function executeServiceOperation(
|
|
419
|
+
serviceName: string,
|
|
420
|
+
operationName: string,
|
|
421
|
+
params: Record<string, any>
|
|
422
|
+
): Promise<ApiResponse> {
|
|
423
|
+
return apiRequest<ApiResponse>(
|
|
424
|
+
'POST',
|
|
425
|
+
\`/services/\${serviceName}/\${operationName}\`,
|
|
426
|
+
params
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
398
430
|
/**
|
|
399
431
|
* Transition entity lifecycle state
|
|
400
432
|
*/
|
package/dist/libs/instance-factories/applications/templates/react/runtime-app-tsx-generator.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
function generateRuntimeAppTsx(context) {
|
|
2
2
|
const { spec } = context;
|
|
3
3
|
const appName = spec?.metadata?.name || spec?.name || "SpecVerse App";
|
|
4
|
-
return `import { useState, useEffect, useMemo } from 'react';
|
|
5
|
-
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
4
|
+
return `import { useState, useEffect, useMemo, useCallback } from 'react';
|
|
5
|
+
import { QueryClient, QueryClientProvider, useQueryClient } from '@tanstack/react-query';
|
|
6
6
|
import yaml from 'js-yaml';
|
|
7
|
-
import { RuntimeViewProvider } from '@specverse/runtime/views/react';
|
|
7
|
+
import { RuntimeViewProvider, useEntitySync } from '@specverse/runtime/views/react';
|
|
8
8
|
import { DevShell } from '@specverse/runtime/views/react';
|
|
9
9
|
import {
|
|
10
10
|
useEntitiesQuery,
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
useExecuteOperationMutation,
|
|
13
13
|
useTransitionStateMutation,
|
|
14
14
|
} from './hooks/useApi';
|
|
15
|
+
import { listEntities, getRuntimeInfo } from './lib/apiClient';
|
|
15
16
|
import devSpecRaw from './dev.specly?raw';
|
|
16
17
|
|
|
17
18
|
// Parse YAML spec
|
|
@@ -64,6 +65,40 @@ function AppContent() {
|
|
|
64
65
|
.catch(() => {});
|
|
65
66
|
}, []);
|
|
66
67
|
|
|
68
|
+
// State-sync contract: WebSocket entity events \u2192 React Query cache
|
|
69
|
+
// invalidation. This is what guarantees "delete in one view is visible
|
|
70
|
+
// in every other view within one WS round-trip" \u2014 it runs at the App
|
|
71
|
+
// level so it's always on, regardless of which tab is active.
|
|
72
|
+
const queryClient = useQueryClient();
|
|
73
|
+
const invalidateEntities = useCallback((modelName: string) => {
|
|
74
|
+
queryClient.invalidateQueries({ queryKey: ['entities', modelName] });
|
|
75
|
+
}, [queryClient]);
|
|
76
|
+
useEntitySync({ invalidateEntities, apiBaseUrl: '/api' });
|
|
77
|
+
|
|
78
|
+
// Name resolver for OperationResultView. Kept in the host so runtime
|
|
79
|
+
// view components never issue raw fetch() calls \u2014 all HTTP goes
|
|
80
|
+
// through apiClient's canonical layer.
|
|
81
|
+
const resolveEntityNames = useCallback(async (ids: string[]): Promise<Record<string, string>> => {
|
|
82
|
+
const names: Record<string, string> = {};
|
|
83
|
+
try {
|
|
84
|
+
const info = await getRuntimeInfo();
|
|
85
|
+
const models = info?.models || [];
|
|
86
|
+
await Promise.all(models.map(async (model: string) => {
|
|
87
|
+
try {
|
|
88
|
+
const entities = await listEntities(model + 'Controller');
|
|
89
|
+
for (const entity of entities) {
|
|
90
|
+
const id = (entity as any)?.id;
|
|
91
|
+
const dataId = (entity as any)?.data?.id;
|
|
92
|
+
const display = (entity as any)?.name || (entity as any)?.title || (entity as any)?.label || id;
|
|
93
|
+
if (id && ids.includes(id)) names[id] = String(display);
|
|
94
|
+
if (dataId && ids.includes(dataId)) names[dataId] = String(display);
|
|
95
|
+
}
|
|
96
|
+
} catch { /* skip this model */ }
|
|
97
|
+
}));
|
|
98
|
+
} catch { /* ignore \u2014 caller falls back to raw IDs */ }
|
|
99
|
+
return names;
|
|
100
|
+
}, []);
|
|
101
|
+
|
|
67
102
|
const runtimeValue = useMemo(() => ({
|
|
68
103
|
useEntitiesQuery,
|
|
69
104
|
useModelSchemaQuery,
|
|
@@ -74,7 +109,9 @@ function AppContent() {
|
|
|
74
109
|
views: [],
|
|
75
110
|
spec: appSpec,
|
|
76
111
|
apiBaseUrl: '/api',
|
|
77
|
-
|
|
112
|
+
invalidateEntities,
|
|
113
|
+
resolveEntityNames,
|
|
114
|
+
}), [appSpec, invalidateEntities, resolveEntityNames]);
|
|
78
115
|
|
|
79
116
|
return (
|
|
80
117
|
<RuntimeViewProvider value={runtimeValue}>
|
package/dist/libs/instance-factories/applications/templates/react/use-api-hooks-generator.js
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
getModelSchema,
|
|
12
12
|
listEntities,
|
|
13
13
|
executeOperation,
|
|
14
|
+
executeServiceOperation,
|
|
14
15
|
transitionState
|
|
15
16
|
} from '../lib/apiClient';
|
|
16
17
|
import type { ModelSchema, Entity, ApiResponse } from '../types/api';
|
|
@@ -49,9 +50,14 @@ export function useEntitiesQuery(controllerName: string | null, modelName: strin
|
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
/**
|
|
52
|
-
* Mutation hook for executing operations
|
|
53
|
-
*
|
|
54
|
-
*
|
|
53
|
+
* Mutation hook for executing operations \u2014 handles both controllers
|
|
54
|
+
* and services. Pass either \`controllerName\` (for CRUD or custom
|
|
55
|
+
* controller actions) or \`serviceName\` (for RPC-style service ops).
|
|
56
|
+
*
|
|
57
|
+
* Matches RuntimeViewProviderValue.useExecuteOperationMutation contract.
|
|
58
|
+
* Service operations invalidate the \`services\` query key; controller
|
|
59
|
+
* operations invalidate the corresponding model's \`entities\` key so
|
|
60
|
+
* all mounted views refetch automatically.
|
|
55
61
|
*/
|
|
56
62
|
export function useExecuteOperationMutation() {
|
|
57
63
|
const queryClient = useQueryClient();
|
|
@@ -59,12 +65,14 @@ export function useExecuteOperationMutation() {
|
|
|
59
65
|
return useMutation({
|
|
60
66
|
mutationFn: ({
|
|
61
67
|
controllerName,
|
|
68
|
+
serviceName,
|
|
62
69
|
operationName,
|
|
63
70
|
data,
|
|
64
71
|
params,
|
|
65
72
|
entityId
|
|
66
73
|
}: {
|
|
67
|
-
controllerName
|
|
74
|
+
controllerName?: string;
|
|
75
|
+
serviceName?: string;
|
|
68
76
|
operationName: string;
|
|
69
77
|
data?: Record<string, any>;
|
|
70
78
|
params?: Record<string, any>;
|
|
@@ -73,11 +81,23 @@ export function useExecuteOperationMutation() {
|
|
|
73
81
|
// Merge: accept both 'data' and 'params' for compatibility
|
|
74
82
|
const mergedParams = { ...(data || params || {}) };
|
|
75
83
|
if (entityId) mergedParams.id = entityId;
|
|
76
|
-
|
|
84
|
+
if (serviceName) {
|
|
85
|
+
return executeServiceOperation(serviceName, operationName, mergedParams);
|
|
86
|
+
}
|
|
87
|
+
if (controllerName) {
|
|
88
|
+
return executeOperation(controllerName, operationName, mergedParams);
|
|
89
|
+
}
|
|
90
|
+
throw new Error('useExecuteOperationMutation: either controllerName or serviceName is required');
|
|
77
91
|
},
|
|
78
92
|
onSuccess: (_data, variables) => {
|
|
79
|
-
|
|
80
|
-
|
|
93
|
+
if (variables.controllerName) {
|
|
94
|
+
const modelName = variables.controllerName.replace(/Controller$/, '');
|
|
95
|
+
queryClient.invalidateQueries({ queryKey: ['entities', modelName] });
|
|
96
|
+
}
|
|
97
|
+
// Service operations aren't tied to a single entity type; no
|
|
98
|
+
// cache key to invalidate here. If a service mutation affects
|
|
99
|
+
// entities, the backend should publish an event that
|
|
100
|
+
// useEntitySync picks up via WebSocket.
|
|
81
101
|
}
|
|
82
102
|
});
|
|
83
103
|
}
|
|
@@ -340,6 +340,22 @@ export async function executeOperation(
|
|
|
340
340
|
return apiRequest<ApiResponse>(method, path, Object.keys(params).length > 0 ? params : null);
|
|
341
341
|
}
|
|
342
342
|
|
|
343
|
+
/**
|
|
344
|
+
* Execute a service operation
|
|
345
|
+
* Services are RPC-style under /services/{serviceName}/{operationName}
|
|
346
|
+
*/
|
|
347
|
+
export async function executeServiceOperation(
|
|
348
|
+
serviceName: string,
|
|
349
|
+
operationName: string,
|
|
350
|
+
params: Record<string, any>
|
|
351
|
+
): Promise<ApiResponse> {
|
|
352
|
+
return apiRequest<ApiResponse>(
|
|
353
|
+
'POST',
|
|
354
|
+
\`/services/\${serviceName}/\${operationName}\`,
|
|
355
|
+
params
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
343
359
|
/**
|
|
344
360
|
* Transition entity lifecycle state
|
|
345
361
|
*/
|
|
@@ -432,6 +448,22 @@ export async function executeOperation(
|
|
|
432
448
|
);
|
|
433
449
|
}
|
|
434
450
|
|
|
451
|
+
/**
|
|
452
|
+
* Execute a service operation
|
|
453
|
+
* Services are RPC-style under /services/{serviceName}/{operationName}
|
|
454
|
+
*/
|
|
455
|
+
export async function executeServiceOperation(
|
|
456
|
+
serviceName: string,
|
|
457
|
+
operationName: string,
|
|
458
|
+
params: Record<string, any>
|
|
459
|
+
): Promise<ApiResponse> {
|
|
460
|
+
return apiRequest<ApiResponse>(
|
|
461
|
+
'POST',
|
|
462
|
+
\`/services/\${serviceName}/\${operationName}\`,
|
|
463
|
+
params
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
|
|
435
467
|
/**
|
|
436
468
|
* Transition entity lifecycle state
|
|
437
469
|
*/
|
|
@@ -13,10 +13,10 @@ export default function generateRuntimeAppTsx(context: TemplateContext): string
|
|
|
13
13
|
const { spec } = context;
|
|
14
14
|
const appName = (spec as any)?.metadata?.name || (spec as any)?.name || 'SpecVerse App';
|
|
15
15
|
|
|
16
|
-
return `import { useState, useEffect, useMemo } from 'react';
|
|
17
|
-
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
16
|
+
return `import { useState, useEffect, useMemo, useCallback } from 'react';
|
|
17
|
+
import { QueryClient, QueryClientProvider, useQueryClient } from '@tanstack/react-query';
|
|
18
18
|
import yaml from 'js-yaml';
|
|
19
|
-
import { RuntimeViewProvider } from '@specverse/runtime/views/react';
|
|
19
|
+
import { RuntimeViewProvider, useEntitySync } from '@specverse/runtime/views/react';
|
|
20
20
|
import { DevShell } from '@specverse/runtime/views/react';
|
|
21
21
|
import {
|
|
22
22
|
useEntitiesQuery,
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
useExecuteOperationMutation,
|
|
25
25
|
useTransitionStateMutation,
|
|
26
26
|
} from './hooks/useApi';
|
|
27
|
+
import { listEntities, getRuntimeInfo } from './lib/apiClient';
|
|
27
28
|
import devSpecRaw from './dev.specly?raw';
|
|
28
29
|
|
|
29
30
|
// Parse YAML spec
|
|
@@ -76,6 +77,40 @@ function AppContent() {
|
|
|
76
77
|
.catch(() => {});
|
|
77
78
|
}, []);
|
|
78
79
|
|
|
80
|
+
// State-sync contract: WebSocket entity events → React Query cache
|
|
81
|
+
// invalidation. This is what guarantees "delete in one view is visible
|
|
82
|
+
// in every other view within one WS round-trip" — it runs at the App
|
|
83
|
+
// level so it's always on, regardless of which tab is active.
|
|
84
|
+
const queryClient = useQueryClient();
|
|
85
|
+
const invalidateEntities = useCallback((modelName: string) => {
|
|
86
|
+
queryClient.invalidateQueries({ queryKey: ['entities', modelName] });
|
|
87
|
+
}, [queryClient]);
|
|
88
|
+
useEntitySync({ invalidateEntities, apiBaseUrl: '/api' });
|
|
89
|
+
|
|
90
|
+
// Name resolver for OperationResultView. Kept in the host so runtime
|
|
91
|
+
// view components never issue raw fetch() calls — all HTTP goes
|
|
92
|
+
// through apiClient's canonical layer.
|
|
93
|
+
const resolveEntityNames = useCallback(async (ids: string[]): Promise<Record<string, string>> => {
|
|
94
|
+
const names: Record<string, string> = {};
|
|
95
|
+
try {
|
|
96
|
+
const info = await getRuntimeInfo();
|
|
97
|
+
const models = info?.models || [];
|
|
98
|
+
await Promise.all(models.map(async (model: string) => {
|
|
99
|
+
try {
|
|
100
|
+
const entities = await listEntities(model + 'Controller');
|
|
101
|
+
for (const entity of entities) {
|
|
102
|
+
const id = (entity as any)?.id;
|
|
103
|
+
const dataId = (entity as any)?.data?.id;
|
|
104
|
+
const display = (entity as any)?.name || (entity as any)?.title || (entity as any)?.label || id;
|
|
105
|
+
if (id && ids.includes(id)) names[id] = String(display);
|
|
106
|
+
if (dataId && ids.includes(dataId)) names[dataId] = String(display);
|
|
107
|
+
}
|
|
108
|
+
} catch { /* skip this model */ }
|
|
109
|
+
}));
|
|
110
|
+
} catch { /* ignore — caller falls back to raw IDs */ }
|
|
111
|
+
return names;
|
|
112
|
+
}, []);
|
|
113
|
+
|
|
79
114
|
const runtimeValue = useMemo(() => ({
|
|
80
115
|
useEntitiesQuery,
|
|
81
116
|
useModelSchemaQuery,
|
|
@@ -86,7 +121,9 @@ function AppContent() {
|
|
|
86
121
|
views: [],
|
|
87
122
|
spec: appSpec,
|
|
88
123
|
apiBaseUrl: '/api',
|
|
89
|
-
|
|
124
|
+
invalidateEntities,
|
|
125
|
+
resolveEntityNames,
|
|
126
|
+
}), [appSpec, invalidateEntities, resolveEntityNames]);
|
|
90
127
|
|
|
91
128
|
return (
|
|
92
129
|
<RuntimeViewProvider value={runtimeValue}>
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
getModelSchema,
|
|
22
22
|
listEntities,
|
|
23
23
|
executeOperation,
|
|
24
|
+
executeServiceOperation,
|
|
24
25
|
transitionState
|
|
25
26
|
} from '../lib/apiClient';
|
|
26
27
|
import type { ModelSchema, Entity, ApiResponse } from '../types/api';
|
|
@@ -59,9 +60,14 @@ export function useEntitiesQuery(controllerName: string | null, modelName: strin
|
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
/**
|
|
62
|
-
* Mutation hook for executing operations
|
|
63
|
-
*
|
|
64
|
-
*
|
|
63
|
+
* Mutation hook for executing operations — handles both controllers
|
|
64
|
+
* and services. Pass either \`controllerName\` (for CRUD or custom
|
|
65
|
+
* controller actions) or \`serviceName\` (for RPC-style service ops).
|
|
66
|
+
*
|
|
67
|
+
* Matches RuntimeViewProviderValue.useExecuteOperationMutation contract.
|
|
68
|
+
* Service operations invalidate the \`services\` query key; controller
|
|
69
|
+
* operations invalidate the corresponding model's \`entities\` key so
|
|
70
|
+
* all mounted views refetch automatically.
|
|
65
71
|
*/
|
|
66
72
|
export function useExecuteOperationMutation() {
|
|
67
73
|
const queryClient = useQueryClient();
|
|
@@ -69,12 +75,14 @@ export function useExecuteOperationMutation() {
|
|
|
69
75
|
return useMutation({
|
|
70
76
|
mutationFn: ({
|
|
71
77
|
controllerName,
|
|
78
|
+
serviceName,
|
|
72
79
|
operationName,
|
|
73
80
|
data,
|
|
74
81
|
params,
|
|
75
82
|
entityId
|
|
76
83
|
}: {
|
|
77
|
-
controllerName
|
|
84
|
+
controllerName?: string;
|
|
85
|
+
serviceName?: string;
|
|
78
86
|
operationName: string;
|
|
79
87
|
data?: Record<string, any>;
|
|
80
88
|
params?: Record<string, any>;
|
|
@@ -83,11 +91,23 @@ export function useExecuteOperationMutation() {
|
|
|
83
91
|
// Merge: accept both 'data' and 'params' for compatibility
|
|
84
92
|
const mergedParams = { ...(data || params || {}) };
|
|
85
93
|
if (entityId) mergedParams.id = entityId;
|
|
86
|
-
|
|
94
|
+
if (serviceName) {
|
|
95
|
+
return executeServiceOperation(serviceName, operationName, mergedParams);
|
|
96
|
+
}
|
|
97
|
+
if (controllerName) {
|
|
98
|
+
return executeOperation(controllerName, operationName, mergedParams);
|
|
99
|
+
}
|
|
100
|
+
throw new Error('useExecuteOperationMutation: either controllerName or serviceName is required');
|
|
87
101
|
},
|
|
88
102
|
onSuccess: (_data, variables) => {
|
|
89
|
-
|
|
90
|
-
|
|
103
|
+
if (variables.controllerName) {
|
|
104
|
+
const modelName = variables.controllerName.replace(/Controller$/, '');
|
|
105
|
+
queryClient.invalidateQueries({ queryKey: ['entities', modelName] });
|
|
106
|
+
}
|
|
107
|
+
// Service operations aren't tied to a single entity type; no
|
|
108
|
+
// cache key to invalidate here. If a service mutation affects
|
|
109
|
+
// entities, the backend should publish an event that
|
|
110
|
+
// useEntitySync picks up via WebSocket.
|
|
91
111
|
}
|
|
92
112
|
});
|
|
93
113
|
}
|