@specverse/engines 4.1.21 → 4.1.23

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 (76) hide show
  1. package/dist/inference/ui-contracts/index.d.ts +38 -0
  2. package/dist/inference/ui-contracts/index.d.ts.map +1 -0
  3. package/dist/inference/ui-contracts/index.js +212 -0
  4. package/dist/inference/ui-contracts/index.js.map +1 -0
  5. package/dist/inference/ui-contracts/rules/_shared.d.ts +32 -0
  6. package/dist/inference/ui-contracts/rules/_shared.d.ts.map +1 -0
  7. package/dist/inference/ui-contracts/rules/_shared.js +103 -0
  8. package/dist/inference/ui-contracts/rules/_shared.js.map +1 -0
  9. package/dist/inference/ui-contracts/rules/action-buttons-present.d.ts +21 -0
  10. package/dist/inference/ui-contracts/rules/action-buttons-present.d.ts.map +1 -0
  11. package/dist/inference/ui-contracts/rules/action-buttons-present.js +62 -0
  12. package/dist/inference/ui-contracts/rules/action-buttons-present.js.map +1 -0
  13. package/dist/inference/ui-contracts/rules/create-reflects-in-list.d.ts +22 -0
  14. package/dist/inference/ui-contracts/rules/create-reflects-in-list.d.ts.map +1 -0
  15. package/dist/inference/ui-contracts/rules/create-reflects-in-list.js +48 -0
  16. package/dist/inference/ui-contracts/rules/create-reflects-in-list.js.map +1 -0
  17. package/dist/inference/ui-contracts/rules/delete-reflects-in-list.d.ts +22 -0
  18. package/dist/inference/ui-contracts/rules/delete-reflects-in-list.d.ts.map +1 -0
  19. package/dist/inference/ui-contracts/rules/delete-reflects-in-list.js +50 -0
  20. package/dist/inference/ui-contracts/rules/delete-reflects-in-list.js.map +1 -0
  21. package/dist/inference/ui-contracts/rules/detail-view-renders.d.ts +24 -0
  22. package/dist/inference/ui-contracts/rules/detail-view-renders.d.ts.map +1 -0
  23. package/dist/inference/ui-contracts/rules/detail-view-renders.js +34 -0
  24. package/dist/inference/ui-contracts/rules/detail-view-renders.js.map +1 -0
  25. package/dist/inference/ui-contracts/rules/evolve-reflects-in-list.d.ts +21 -0
  26. package/dist/inference/ui-contracts/rules/evolve-reflects-in-list.d.ts.map +1 -0
  27. package/dist/inference/ui-contracts/rules/evolve-reflects-in-list.js +53 -0
  28. package/dist/inference/ui-contracts/rules/evolve-reflects-in-list.js.map +1 -0
  29. package/dist/inference/ui-contracts/rules/form-shows-required-indicators.d.ts +15 -0
  30. package/dist/inference/ui-contracts/rules/form-shows-required-indicators.d.ts.map +1 -0
  31. package/dist/inference/ui-contracts/rules/form-shows-required-indicators.js +38 -0
  32. package/dist/inference/ui-contracts/rules/form-shows-required-indicators.js.map +1 -0
  33. package/dist/inference/ui-contracts/rules/hasmany-shows-children-in-detail.d.ts +17 -0
  34. package/dist/inference/ui-contracts/rules/hasmany-shows-children-in-detail.d.ts.map +1 -0
  35. package/dist/inference/ui-contracts/rules/hasmany-shows-children-in-detail.js +39 -0
  36. package/dist/inference/ui-contracts/rules/hasmany-shows-children-in-detail.js.map +1 -0
  37. package/dist/inference/ui-contracts/rules/lifecycle-state-visible-in-detail.d.ts +25 -0
  38. package/dist/inference/ui-contracts/rules/lifecycle-state-visible-in-detail.d.ts.map +1 -0
  39. package/dist/inference/ui-contracts/rules/lifecycle-state-visible-in-detail.js +66 -0
  40. package/dist/inference/ui-contracts/rules/lifecycle-state-visible-in-detail.js.map +1 -0
  41. package/dist/inference/ui-contracts/rules/list-shows-business-columns.d.ts +17 -0
  42. package/dist/inference/ui-contracts/rules/list-shows-business-columns.d.ts.map +1 -0
  43. package/dist/inference/ui-contracts/rules/list-shows-business-columns.js +39 -0
  44. package/dist/inference/ui-contracts/rules/list-shows-business-columns.js.map +1 -0
  45. package/dist/inference/ui-contracts/rules/list-view-renders.d.ts +19 -0
  46. package/dist/inference/ui-contracts/rules/list-view-renders.d.ts.map +1 -0
  47. package/dist/inference/ui-contracts/rules/list-view-renders.js +29 -0
  48. package/dist/inference/ui-contracts/rules/list-view-renders.js.map +1 -0
  49. package/dist/inference/ui-contracts/rules/nav-has-model-entries.d.ts +20 -0
  50. package/dist/inference/ui-contracts/rules/nav-has-model-entries.d.ts.map +1 -0
  51. package/dist/inference/ui-contracts/rules/nav-has-model-entries.js +29 -0
  52. package/dist/inference/ui-contracts/rules/nav-has-model-entries.js.map +1 -0
  53. package/dist/inference/ui-contracts/test-case-types.d.ts +126 -0
  54. package/dist/inference/ui-contracts/test-case-types.d.ts.map +1 -0
  55. package/dist/inference/ui-contracts/test-case-types.js +14 -0
  56. package/dist/inference/ui-contracts/test-case-types.js.map +1 -0
  57. package/dist/inference/ui-contracts/translator.d.ts +17 -0
  58. package/dist/inference/ui-contracts/translator.d.ts.map +1 -0
  59. package/dist/inference/ui-contracts/translator.js +127 -0
  60. package/dist/inference/ui-contracts/translator.js.map +1 -0
  61. package/dist/libs/instance-factories/applications/templates/react/api-client-generator.js +37 -2
  62. package/dist/libs/instance-factories/applications/templates/react/runtime-app-tsx-generator.js +41 -4
  63. package/dist/libs/instance-factories/applications/templates/react/use-api-hooks-generator.js +27 -7
  64. package/dist/libs/instance-factories/scaffolding/templates/generic/package-json-generator.js +10 -0
  65. package/dist/libs/instance-factories/views/templates/react/components-generator.js +34 -23
  66. package/dist/libs/instance-factories/views/templates/react/hooks-generator.js +51 -81
  67. package/dist/realize/index.d.ts.map +1 -1
  68. package/dist/realize/index.js +204 -0
  69. package/dist/realize/index.js.map +1 -1
  70. package/libs/instance-factories/applications/templates/react/api-client-generator.ts +37 -2
  71. package/libs/instance-factories/applications/templates/react/runtime-app-tsx-generator.ts +41 -4
  72. package/libs/instance-factories/applications/templates/react/use-api-hooks-generator.ts +27 -7
  73. package/libs/instance-factories/scaffolding/templates/generic/package-json-generator.ts +13 -0
  74. package/libs/instance-factories/views/templates/react/components-generator.ts +34 -23
  75. package/libs/instance-factories/views/templates/react/hooks-generator.ts +72 -88
  76. package/package.json +1 -1
@@ -135,13 +135,14 @@ function pluralize(s) {
135
135
  function generateListView(name, model, lower, plural, api, classified, belongsTo, lifecycle, view) {
136
136
  const displayCols = classified.business.slice(0, 5);
137
137
  const statusField = lifecycle?.statusField || classified.lifecycle[0]?.name;
138
+ const apiPath = api.replace(/^\/api/, "");
138
139
  const relFetches = belongsTo.map((r) => {
139
140
  const tLower = r.target.charAt(0).toLowerCase() + r.target.slice(1);
140
141
  return ` const [${tLower}Map, set${r.target}Map] = useState<Record<string, any>>({});`;
141
142
  }).join("\n");
142
143
  const relEffects = belongsTo.map((r) => {
143
144
  const tLower = r.target.charAt(0).toLowerCase() + r.target.slice(1);
144
- return ` fetch('/api/${tLower}s').then(r => r.json()).then(data => {
145
+ return ` apiRequest('GET', '/${tLower}s').then(data => {
145
146
  if (Array.isArray(data)) {
146
147
  const m: Record<string, any> = {};
147
148
  data.forEach(e => { m[e.id] = e; });
@@ -156,6 +157,7 @@ function generateListView(name, model, lower, plural, api, classified, belongsTo
156
157
  });
157
158
  return `import { useState, useEffect } from 'react';
158
159
  import { Link } from 'react-router-dom';
160
+ import { apiRequest } from '../lib/apiClient';
159
161
  import { getEntityDisplayName } from '../lib/field-helpers';
160
162
  import { formatValue, formatDate, StatusBadge } from '../lib/view-helpers';
161
163
 
@@ -165,7 +167,7 @@ function ${name}() {
165
167
  ${relFetches}
166
168
 
167
169
  useEffect(() => {
168
- fetch('${api}').then(r => r.json()).then(data => {
170
+ apiRequest('GET', '${apiPath}').then(data => {
169
171
  setItems(Array.isArray(data) ? data : []);
170
172
  setLoading(false);
171
173
  }).catch(() => setLoading(false));
@@ -228,6 +230,7 @@ export default ${name};
228
230
  }
229
231
  function generateDetailView(name, model, lower, plural, api, classified, belongsTo, hasMany, lifecycle, view) {
230
232
  const statusField = lifecycle?.statusField || classified.lifecycle[0]?.name;
233
+ const apiPath = api.replace(/^\/api/, "");
231
234
  const relFetches = belongsTo.map((r) => {
232
235
  const tLower = r.target.charAt(0).toLowerCase() + r.target.slice(1);
233
236
  return ` const [${tLower}Ref, set${r.target}Ref] = useState<any>(null);`;
@@ -235,7 +238,7 @@ function generateDetailView(name, model, lower, plural, api, classified, belongs
235
238
  const relEffects = belongsTo.map((r) => {
236
239
  const tLower = r.target.charAt(0).toLowerCase() + r.target.slice(1);
237
240
  const fk = `${r.name}_id`;
238
- return ` if (data.${fk}) fetch(\`/api/${tLower}s/\${data.${fk}}\`).then(r => r.json()).then(d => set${r.target}Ref(d)).catch(() => {});`;
241
+ return ` if (data.${fk}) apiRequest('GET', \`/${tLower}s/\${data.${fk}}\`).then(d => set${r.target}Ref(d)).catch(() => {});`;
239
242
  }).join("\n");
240
243
  const hasManyFetches = hasMany.map((r) => {
241
244
  const tLower = r.target.charAt(0).toLowerCase() + r.target.slice(1);
@@ -243,10 +246,11 @@ function generateDetailView(name, model, lower, plural, api, classified, belongs
243
246
  }).join("\n");
244
247
  const hasManyEffects = hasMany.map((r) => {
245
248
  const tLower = r.target.charAt(0).toLowerCase() + r.target.slice(1);
246
- return ` fetch(\`/api/${tLower}s?${lower}Id=\${data.id}\`).then(r => r.json()).then(d => set${capitalize(r.name)}Items(Array.isArray(d) ? d : [])).catch(() => {});`;
249
+ return ` apiRequest('GET', \`/${tLower}s?${lower}Id=\${data.id}\`).then(d => set${capitalize(r.name)}Items(Array.isArray(d) ? d : [])).catch(() => {});`;
247
250
  }).join("\n");
248
251
  return `import { useState, useEffect } from 'react';
249
252
  import { Link, useSearchParams, useNavigate } from 'react-router-dom';
253
+ import { apiRequest } from '../lib/apiClient';
250
254
  import { getEntityDisplayName } from '../lib/field-helpers';
251
255
  import { formatValue, formatDate, StatusBadge } from '../lib/view-helpers';
252
256
 
@@ -261,7 +265,7 @@ ${hasManyFetches}
261
265
 
262
266
  useEffect(() => {
263
267
  if (!id) { setLoading(false); return; }
264
- fetch(\`${api}/\${id}\`).then(r => r.ok ? r.json() : null).then(data => {
268
+ apiRequest('GET', \`${apiPath}/\${id}\`).then(data => {
265
269
  setItem(data);
266
270
  setLoading(false);
267
271
  if (data) {
@@ -276,7 +280,7 @@ ${hasManyEffects}
276
280
 
277
281
  const handleDelete = async () => {
278
282
  if (!confirm('Delete this ${lower}?')) return;
279
- await fetch(\`${api}/\${id}\`, { method: 'DELETE' });
283
+ await apiRequest('DELETE', \`${apiPath}/\${id}\`);
280
284
  navigate('/${lower}list');
281
285
  };
282
286
 
@@ -358,12 +362,13 @@ export default ${name};
358
362
  function generateFormView(name, model, lower, plural, api, classified, belongsTo, lifecycle, view) {
359
363
  const editableFields = [...classified.business, ...classified.lifecycle];
360
364
  const statusField = lifecycle?.statusField;
365
+ const apiPath = api.replace(/^\/api/, "");
361
366
  const relStates = belongsTo.map((r) => {
362
367
  return ` const [${r.target.charAt(0).toLowerCase() + r.target.slice(1)}Options, set${r.target}Options] = useState<any[]>([]);`;
363
368
  }).join("\n");
364
369
  const relEffects = belongsTo.map((r) => {
365
370
  const tLower = r.target.charAt(0).toLowerCase() + r.target.slice(1);
366
- return ` fetch('/api/${tLower}s').then(r => r.json()).then(d => set${r.target}Options(Array.isArray(d) ? d : [])).catch(() => {});`;
371
+ return ` apiRequest('GET', '/${tLower}s').then(d => set${r.target}Options(Array.isArray(d) ? d : [])).catch(() => {});`;
367
372
  }).join("\n");
368
373
  function inputForField(attr) {
369
374
  const { name: n, type, required, values } = attr;
@@ -390,6 +395,7 @@ ${lifecycle.states.map((s) => ` <option value="${s}">${s.replace(/[_-]/
390
395
  }
391
396
  return `import { useState, useEffect } from 'react';
392
397
  import { useSearchParams, useNavigate } from 'react-router-dom';
398
+ import { apiRequest } from '../lib/apiClient';
393
399
  import { getEntityDisplayName } from '../lib/field-helpers';
394
400
 
395
401
  function ${name}() {
@@ -403,7 +409,7 @@ ${relStates}
403
409
 
404
410
  useEffect(() => {
405
411
  if (editId) {
406
- fetch(\`${api}/\${editId}\`).then(r => r.json()).then(data => setForm(data || {})).catch(() => {});
412
+ apiRequest('GET', \`${apiPath}/\${editId}\`).then(data => setForm(data || {})).catch(() => {});
407
413
  }
408
414
  ${relEffects}
409
415
  }, [editId]);
@@ -418,17 +424,12 @@ ${relEffects}
418
424
  setSaving(true);
419
425
  setError('');
420
426
  try {
421
- const method = editId ? 'PUT' : 'POST';
422
- const url = editId ? \`${api}/\${editId}\` : '${api}';
423
- const res = await fetch(url, {
424
- method,
425
- headers: { 'Content-Type': 'application/json' },
426
- body: JSON.stringify(form),
427
- });
428
- if (!res.ok) {
429
- const err = await res.json().catch(() => ({}));
430
- throw new Error(err.message || 'Save failed');
427
+ if (editId) {
428
+ await apiRequest('PUT', \`${apiPath}/\${editId}\`, form);
429
+ } else {
430
+ await apiRequest('POST', '${apiPath}', form);
431
431
  }
432
+ // apiRequest throws on non-2xx so reaching here means success.
432
433
  navigate('/${lower}list');
433
434
  } catch (err: any) {
434
435
  setError(err.message);
@@ -490,7 +491,9 @@ export default ${name};
490
491
  function generateDashboardView(name, model, lower, plural, api, classified, view, modelDef) {
491
492
  const numericAttrs = classified.business.filter((a) => ["integer", "number", "money", "decimal", "float"].includes(a.type.toLowerCase()));
492
493
  const metricNames = numericAttrs.map((a) => a.name).slice(0, 4);
494
+ const apiPath = api.replace(/^\/api/, "");
493
495
  return `import { useState, useEffect } from 'react';
496
+ import { apiRequest } from '../lib/apiClient';
494
497
  import { getEntityDisplayName } from '../lib/field-helpers';
495
498
  import { formatDate, StatusBadge } from '../lib/view-helpers';
496
499
 
@@ -499,7 +502,7 @@ function ${name}() {
499
502
  const [loading, setLoading] = useState(true);
500
503
 
501
504
  useEffect(() => {
502
- fetch('${api}').then(r => r.json()).then(data => {
505
+ apiRequest('GET', '${apiPath}').then(data => {
503
506
  setItems(Array.isArray(data) ? data : []);
504
507
  setLoading(false);
505
508
  }).catch(() => setLoading(false));
@@ -552,8 +555,10 @@ export default ${name};
552
555
  function generateBoardView(name, model, lower, plural, api, lifecycle, view) {
553
556
  const states = lifecycle?.states || ["todo", "in_progress", "done"];
554
557
  const statusField = lifecycle?.statusField || "status";
558
+ const apiPath = api.replace(/^\/api/, "");
555
559
  return `import { useState, useEffect } from 'react';
556
560
  import { Link } from 'react-router-dom';
561
+ import { apiRequest } from '../lib/apiClient';
557
562
  import { getEntityDisplayName } from '../lib/field-helpers';
558
563
  import { statusColor } from '../lib/view-helpers';
559
564
 
@@ -562,7 +567,7 @@ function ${name}() {
562
567
  const [loading, setLoading] = useState(true);
563
568
 
564
569
  useEffect(() => {
565
- fetch('${api}').then(r => r.json()).then(data => {
570
+ apiRequest('GET', '${apiPath}').then(data => {
566
571
  setItems(Array.isArray(data) ? data : []);
567
572
  setLoading(false);
568
573
  }).catch(() => setLoading(false));
@@ -614,8 +619,10 @@ export default ${name};
614
619
  `;
615
620
  }
616
621
  function generateTimelineView(name, model, lower, plural, api, view) {
622
+ const apiPath = api.replace(/^\/api/, "");
617
623
  return `import { useState, useEffect } from 'react';
618
624
  import { Link } from 'react-router-dom';
625
+ import { apiRequest } from '../lib/apiClient';
619
626
  import { getEntityDisplayName } from '../lib/field-helpers';
620
627
  import { formatDate, StatusBadge } from '../lib/view-helpers';
621
628
 
@@ -624,7 +631,7 @@ function ${name}() {
624
631
  const [loading, setLoading] = useState(true);
625
632
 
626
633
  useEffect(() => {
627
- fetch('${api}').then(r => r.json()).then(data => {
634
+ apiRequest('GET', '${apiPath}').then(data => {
628
635
  const sorted = (Array.isArray(data) ? data : []).sort((a, b) =>
629
636
  new Date(b.createdAt || 0).getTime() - new Date(a.createdAt || 0).getTime()
630
637
  );
@@ -669,7 +676,9 @@ function generateCalendarView(name, model, lower, plural, api, view, modelDef) {
669
676
  const dateField = attrs.find(
670
677
  (a) => ["startdate", "duedate", "scheduledat", "eventdate", "date"].includes(a.name.toLowerCase())
671
678
  )?.name || "createdAt";
679
+ const apiPath = api.replace(/^\/api/, "");
672
680
  return `import { useState, useEffect } from 'react';
681
+ import { apiRequest } from '../lib/apiClient';
673
682
  import { getEntityDisplayName } from '../lib/field-helpers';
674
683
 
675
684
  function ${name}() {
@@ -678,7 +687,7 @@ function ${name}() {
678
687
  const [currentMonth, setCurrentMonth] = useState(new Date());
679
688
 
680
689
  useEffect(() => {
681
- fetch('${api}').then(r => r.json()).then(data => {
690
+ apiRequest('GET', '${apiPath}').then(data => {
682
691
  setItems(Array.isArray(data) ? data : []);
683
692
  setLoading(false);
684
693
  }).catch(() => setLoading(false));
@@ -737,14 +746,16 @@ export default ${name};
737
746
  function generateAnalyticsView(name, model, lower, plural, api, classified, lifecycle, view, modelDef) {
738
747
  const numericAttrs = classified.business.filter((a) => ["integer", "number", "money", "decimal", "float"].includes(a.type.toLowerCase()));
739
748
  const statusField = lifecycle?.statusField || "status";
749
+ const apiPath = api.replace(/^\/api/, "");
740
750
  return `import { useState, useEffect } from 'react';
751
+ import { apiRequest } from '../lib/apiClient';
741
752
 
742
753
  function ${name}() {
743
754
  const [items, setItems] = useState<any[]>([]);
744
755
  const [loading, setLoading] = useState(true);
745
756
 
746
757
  useEffect(() => {
747
- fetch('${api}').then(r => r.json()).then(data => {
758
+ apiRequest('GET', '${apiPath}').then(data => {
748
759
  setItems(Array.isArray(data) ? data : []);
749
760
  setLoading(false);
750
761
  }).catch(() => setLoading(false));
@@ -1,111 +1,81 @@
1
1
  function generateReactHook(context) {
2
- const { model, controller, spec } = context;
2
+ const { model } = context;
3
3
  if (!model) {
4
4
  throw new Error("Model is required in template context");
5
5
  }
6
6
  const modelName = model.name;
7
7
  const hookName = `use${modelName}`;
8
8
  const controllerName = `${modelName}Controller`;
9
+ const singular = modelName.charAt(0).toLowerCase() + modelName.slice(1);
10
+ const plural = singular + "s";
9
11
  return `/**
10
12
  * ${hookName}
11
- * Custom React hook for ${modelName} data fetching and mutations
13
+ *
14
+ * Thin delegator over the canonical useApi hooks. All state flows
15
+ * through \`['entities', '${modelName}']\` so WebSocket invalidation
16
+ * reaches every view consuming this hook.
12
17
  */
13
18
 
14
- import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
15
- import { executeOperation } from '../lib/apiClient';
19
+ import { useMemo } from 'react';
20
+ import {
21
+ useEntitiesQuery,
22
+ useExecuteOperationMutation,
23
+ } from './useApi';
16
24
  import type { ${modelName} } from '../types/${modelName}';
17
25
 
18
- interface Use${modelName}Options {
26
+ interface ${capitalize(hookName)}Options {
19
27
  id?: string;
20
28
  list?: boolean;
21
29
  filters?: Record<string, any>;
22
30
  }
23
31
 
24
- /**
25
- * ${hookName} - Fetch and mutate ${modelName} data
26
- */
27
- export function ${hookName}(options: Use${modelName}Options = {}) {
28
- const queryClient = useQueryClient();
29
- const { id, list, filters } = options;
30
-
31
- // Fetch single ${modelName}
32
- const { data: ${modelName.toLowerCase()}, isLoading: singleLoading, error: singleError } = useQuery({
33
- queryKey: ['${modelName.toLowerCase()}', id],
34
- queryFn: async () => {
35
- if (!id) return null;
36
- return await executeOperation('${controllerName}', 'retrieve', { id });
37
- },
38
- enabled: !!id && !list,
39
- });
40
-
41
- // Fetch list of ${modelName}s
42
- const { data: ${modelName.toLowerCase()}s, isLoading: listLoading, error: listError } = useQuery({
43
- queryKey: ['${modelName.toLowerCase()}s', filters],
44
- queryFn: async () => {
45
- return await executeOperation('${controllerName}', 'list', filters || {});
46
- },
47
- enabled: list,
48
- });
49
-
50
- const isLoading = list ? listLoading : singleLoading;
51
- const error = list ? listError : singleError;
52
-
53
- // Create mutation
54
- const createMutation = useMutation({
55
- mutationFn: async (data: Partial<${modelName}>) => {
56
- return await executeOperation('${controllerName}', 'create', data);
57
- },
58
- onSuccess: () => {
59
- queryClient.invalidateQueries({ queryKey: ['${modelName.toLowerCase()}s'] });
60
- },
61
- });
32
+ export function ${hookName}(opts: ${capitalize(hookName)}Options = {}) {
33
+ const { id, filters: _filters } = opts;
62
34
 
63
- // Update mutation
64
- const updateMutation = useMutation({
65
- mutationFn: async ({ id, data }: { id: string; data: Partial<${modelName}> }) => {
66
- return await executeOperation('${controllerName}', 'update', { id, ...data });
67
- },
68
- onSuccess: (_, variables) => {
69
- queryClient.invalidateQueries({ queryKey: ['${modelName.toLowerCase()}', variables.id] });
70
- queryClient.invalidateQueries({ queryKey: ['${modelName.toLowerCase()}s'] });
71
- },
72
- });
35
+ // Canonical list query \u2014 one cache key per model. The list flag
36
+ // is preserved for backwards compatibility but the query always
37
+ // runs; React Query dedupes across consumers automatically.
38
+ const query = useEntitiesQuery('${controllerName}', '${modelName}');
39
+ const ${plural} = (query.data as ${modelName}[] | undefined) || [];
73
40
 
74
- // Delete mutation
75
- const deleteMutation = useMutation({
76
- mutationFn: async (id: string) => {
77
- return await executeOperation('${controllerName}', 'delete', { id });
78
- },
79
- onSuccess: () => {
80
- queryClient.invalidateQueries({ queryKey: ['${modelName.toLowerCase()}s'] });
81
- },
82
- });
83
-
84
- // Refetch functions
85
- const refetch = () => {
86
- if (list) {
87
- queryClient.invalidateQueries({ queryKey: ['${modelName.toLowerCase()}s'] });
88
- } else if (id) {
89
- queryClient.invalidateQueries({ queryKey: ['${modelName.toLowerCase()}', id] });
41
+ // Single-entity lookup is client-side against the canonical list
42
+ // so both the detail view and the list view share one source of
43
+ // truth. If the caller needs server-side hydration for a single
44
+ // entity, they should call useEntitiesQuery directly with a
45
+ // filtered controller endpoint.
46
+ const ${singular} = useMemo<${modelName} | null>(() => {
47
+ if (!id) return null;
48
+ for (const entity of ${plural}) {
49
+ const entityId = (entity as any)?.id || (entity as any)?.data?.id;
50
+ if (entityId === id) return entity;
90
51
  }
91
- };
52
+ return null;
53
+ }, [id, ${plural}]);
54
+
55
+ const mutation = useExecuteOperationMutation();
92
56
 
93
57
  return {
94
- ${modelName.toLowerCase()},
95
- ${modelName.toLowerCase()}s,
96
- isLoading,
97
- error,
98
- refetch,
99
- create: createMutation.mutateAsync,
100
- update: updateMutation.mutateAsync,
101
- delete: deleteMutation.mutateAsync,
102
- isCreating: createMutation.isPending,
103
- isUpdating: updateMutation.isPending,
104
- isDeleting: deleteMutation.isPending,
58
+ ${singular},
59
+ ${plural},
60
+ isLoading: query.isLoading,
61
+ error: query.error,
62
+ refetch: () => { /* handled by useEntitiesQuery refetchInterval + WS invalidation */ },
63
+ create: (data: Partial<${modelName}>) =>
64
+ mutation.mutateAsync({ controllerName: '${controllerName}', operationName: 'create', data: data as Record<string, unknown> }),
65
+ update: (args: { id: string; data: Partial<${modelName}> }) =>
66
+ mutation.mutateAsync({ controllerName: '${controllerName}', operationName: 'update', entityId: args.id, data: args.data as Record<string, unknown> }),
67
+ delete: (entityId: string) =>
68
+ mutation.mutateAsync({ controllerName: '${controllerName}', operationName: 'delete', entityId, data: {} }),
69
+ isCreating: mutation.isPending,
70
+ isUpdating: mutation.isPending,
71
+ isDeleting: mutation.isPending,
105
72
  };
106
73
  }
107
74
  `;
108
75
  }
76
+ function capitalize(s) {
77
+ return s.charAt(0).toUpperCase() + s.slice(1);
78
+ }
109
79
  export {
110
80
  generateReactHook as default
111
81
  };
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/realize/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,cAAc,kBAAkB,CAAC;AAGjC,cAAc,kBAAkB,CAAC;AAGjC,cAAc,uBAAuB,CAAC;AAGtC,OAAO,EAAE,sBAAsB,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AACpF,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAGvD,OAAO,EAAE,mBAAmB,EAAE,MAAM,6BAA6B,CAAC;AAMlE,OAAO,KAAK,EAAE,aAAa,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AASnF,cAAM,sBAAuB,YAAW,aAAa;IACnD,IAAI,SAAa;IACjB,OAAO,SAAW;IAClB,YAAY,WAA+E;IAE3F,OAAO,CAAC,OAAO,CAAa;IAC5B,OAAO,CAAC,QAAQ,CAAa;IAC7B,OAAO,CAAC,aAAa,CAAa;IAClC,OAAO,CAAC,QAAQ,CAAa;IAC7B,OAAO,CAAC,WAAW,CAAS;IAEtB,UAAU,CAAC,MAAM,CAAC,EAAE;QACxB,YAAY,CAAC,EAAE,MAAM,CAAC;QACtB,UAAU,CAAC,EAAE,MAAM,CAAC;KACrB,GAAG,OAAO,CAAC,IAAI,CAAC;IAkBjB,OAAO,IAAI,UAAU;IAIrB,OAAO,CAAC,UAAU,EAAE,MAAM,GAAG,GAAG;IAK1B,QAAQ,CAAC,QAAQ,EAAE,GAAG,EAAE,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,GAAG,GAAG,OAAO,CAAC,eAAe,CAAC;IAKvF;;;OAGG;IACG,UAAU,CAAC,IAAI,EAAE,GAAG,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,KAAK,EAAE,MAAM,EAAE,CAAC;QAAC,MAAM,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC;IA4lB9F;;;OAGG;YACW,UAAU;IAwDxB,OAAO,CAAC,oBAAoB;CAuB7B;AAED,eAAO,MAAM,MAAM,wBAA+B,CAAC;AACnD,eAAe,MAAM,CAAC;AACtB,OAAO,EAAE,sBAAsB,EAAE,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/realize/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,cAAc,kBAAkB,CAAC;AAGjC,cAAc,kBAAkB,CAAC;AAGjC,cAAc,uBAAuB,CAAC;AAGtC,OAAO,EAAE,sBAAsB,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AACpF,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAGvD,OAAO,EAAE,mBAAmB,EAAE,MAAM,6BAA6B,CAAC;AAMlE,OAAO,KAAK,EAAE,aAAa,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AASnF,cAAM,sBAAuB,YAAW,aAAa;IACnD,IAAI,SAAa;IACjB,OAAO,SAAW;IAClB,YAAY,WAA+E;IAE3F,OAAO,CAAC,OAAO,CAAa;IAC5B,OAAO,CAAC,QAAQ,CAAa;IAC7B,OAAO,CAAC,aAAa,CAAa;IAClC,OAAO,CAAC,QAAQ,CAAa;IAC7B,OAAO,CAAC,WAAW,CAAS;IAEtB,UAAU,CAAC,MAAM,CAAC,EAAE;QACxB,YAAY,CAAC,EAAE,MAAM,CAAC;QACtB,UAAU,CAAC,EAAE,MAAM,CAAC;KACrB,GAAG,OAAO,CAAC,IAAI,CAAC;IAkBjB,OAAO,IAAI,UAAU;IAIrB,OAAO,CAAC,UAAU,EAAE,MAAM,GAAG,GAAG;IAK1B,QAAQ,CAAC,QAAQ,EAAE,GAAG,EAAE,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,GAAG,GAAG,OAAO,CAAC,eAAe,CAAC;IAKvF;;;OAGG;IACG,UAAU,CAAC,IAAI,EAAE,GAAG,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,KAAK,EAAE,MAAM,EAAE,CAAC;QAAC,MAAM,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC;IAoyB9F;;;OAGG;YACW,UAAU;IAwDxB,OAAO,CAAC,oBAAoB;CAuB7B;AAED,eAAO,MAAM,MAAM,wBAA+B,CAAC;AACnD,eAAe,MAAM,CAAC;AACtB,OAAO,EAAE,sBAAsB,EAAE,CAAC"}
@@ -665,6 +665,210 @@ export default {
665
665
  catch (e) {
666
666
  errors.push(`Assets: ${e.message}`);
667
667
  }
668
+ // 13. UI contract tests (Phase 1 of the inference test framework).
669
+ // Rules live in engines/src/inference/ui-contracts/. Each rule is
670
+ // a pure function (spec) => TestCase[]; the translator renders each
671
+ // rule's output into a single .spec.ts file under
672
+ // generated/code/tests/contract/. A Playwright config and the
673
+ // test:contract npm script are emitted alongside.
674
+ try {
675
+ const { generateContractTests, normalizeSpec } = await import('../inference/ui-contracts/index.js');
676
+ const normalized = normalizeSpec(spec);
677
+ const contractFiles = generateContractTests(normalized);
678
+ if (contractFiles.length > 0) {
679
+ const contractDir = join(outputDir, 'tests', 'contract');
680
+ if (!existsSync(contractDir))
681
+ mkdirSync(contractDir, { recursive: true });
682
+ let totalTests = 0;
683
+ for (const f of contractFiles) {
684
+ writeFileSync(join(contractDir, f.filename), f.contents, 'utf-8');
685
+ totalTests += f.testCount;
686
+ }
687
+ // Playwright config — written once at the output root. Uses
688
+ // the tests/contract directory exclusively; behavioral tests
689
+ // (if any) live under tests/e2e/ and are handled by a
690
+ // separate playwright.behavioral.config.ts (not auto-generated).
691
+ const playwrightConfigPath = join(outputDir, 'playwright.contract.config.ts');
692
+ const playwrightConfig = `/**
693
+ * Playwright config for auto-generated UI contract tests.
694
+ * DO NOT EDIT — regenerated every \`spv realize\` run.
695
+ *
696
+ * Behavioral tests (hand-written) live under tests/e2e/ and use a
697
+ * separate playwright.config.ts that you maintain yourself.
698
+ */
699
+ import { defineConfig } from '@playwright/test';
700
+
701
+ export default defineConfig({
702
+ testDir: './tests/contract',
703
+ timeout: 30_000,
704
+ expect: { timeout: 10_000 },
705
+ reporter: 'list',
706
+ use: {
707
+ baseURL: process.env.SPECVERSE_FRONTEND_URL || 'http://localhost:5173',
708
+ trace: 'on-first-retry',
709
+ },
710
+ projects: [
711
+ {
712
+ name: 'chromium',
713
+ use: { browserName: 'chromium' },
714
+ },
715
+ ],
716
+ });
717
+ `;
718
+ writeFileSync(playwrightConfigPath, playwrightConfig, 'utf-8');
719
+ console.log(` ✅ UI contract tests: ${contractFiles.length} file(s), ${totalTests} test case(s)`);
720
+ }
721
+ }
722
+ catch (e) {
723
+ errors.push(`UI contract tests: ${e.message}`);
724
+ }
725
+ // 14. Implementation conformance checks (Phase 5 of the inference
726
+ // test framework). Static, grep-level checks that verify the
727
+ // current React-Query-based implementation honors the design
728
+ // contract. Runs in milliseconds, no browser needed. When the
729
+ // runtime is rewritten in a different state library, this script
730
+ // gets rewritten too — the design-contract Playwright tests above
731
+ // are the stable layer, these checks are the short-lived ratchet
732
+ // that enforces "no escape hatches in the current implementation".
733
+ try {
734
+ const scriptsDir = join(outputDir, 'scripts');
735
+ if (!existsSync(scriptsDir))
736
+ mkdirSync(scriptsDir, { recursive: true });
737
+ const conformanceScript = `#!/usr/bin/env node
738
+ /**
739
+ * Implementation conformance checks — auto-generated.
740
+ * DO NOT EDIT — regenerated every \`spv realize\` run.
741
+ *
742
+ * Verifies the current React-Query-based runtime implementation
743
+ * doesn't have escape hatches that could violate the design
744
+ * contract:
745
+ * 1. no-raw-fetch-in-views — view components go through apiClient
746
+ * 2. no-bare-usequery-in-views — view components use named hooks
747
+ * 3. websocket-invalidates-cache — WS events trigger cache refetch
748
+ * 4. mutation-hooks-invalidate — mutations invalidate after success
749
+ *
750
+ * Exits 1 on any failure. Fast (grep-level, no browser).
751
+ */
752
+ import { readFileSync, readdirSync, statSync } from 'fs';
753
+ import { join, dirname } from 'path';
754
+ import { fileURLToPath } from 'url';
755
+
756
+ const __dirname = dirname(fileURLToPath(import.meta.url));
757
+ const root = join(__dirname, '..');
758
+ let failed = false;
759
+
760
+ function walk(dir, pattern, exclude = []) {
761
+ const hits = [];
762
+ const visit = (d) => {
763
+ let stat;
764
+ try { stat = statSync(d); } catch { return; }
765
+ if (!stat.isDirectory()) return;
766
+ for (const entry of readdirSync(d)) {
767
+ const p = join(d, entry);
768
+ if (exclude.some(e => p.includes(e))) continue;
769
+ let s;
770
+ try { s = statSync(p); } catch { continue; }
771
+ if (s.isDirectory()) visit(p);
772
+ else if (/\\.(ts|tsx|js|jsx|mjs)$/.test(entry)) {
773
+ const lines = readFileSync(p, 'utf-8').split('\\n');
774
+ lines.forEach((line, i) => {
775
+ if (pattern.test(line)) hits.push({ file: p, line: i + 1, text: line.trim() });
776
+ });
777
+ }
778
+ }
779
+ };
780
+ visit(dir);
781
+ return hits;
782
+ }
783
+
784
+ function report(name, ok, msg, hits) {
785
+ console.log(\`\${ok ? '\\u2713' : '\\u2717'} \${name}\${msg ? ' \\u2014 ' + msg : ''}\`);
786
+ if (!ok) {
787
+ failed = true;
788
+ if (hits) {
789
+ for (const h of hits.slice(0, 5)) {
790
+ console.log(\` \${h.file}:\${h.line} \${h.text.slice(0, 120)}\`);
791
+ }
792
+ if (hits.length > 5) console.log(\` ... and \${hits.length - 5} more\`);
793
+ }
794
+ }
795
+ }
796
+
797
+ const frontendSrc = join(root, 'frontend', 'src');
798
+ const componentsDir = join(frontendSrc, 'components');
799
+ const pagesDir = join(frontendSrc, 'pages');
800
+
801
+ // 1. no-raw-fetch-in-views
802
+ {
803
+ const hits = [
804
+ ...walk(componentsDir, /\\bfetch\\s*\\(/),
805
+ ...walk(pagesDir, /\\bfetch\\s*\\(/),
806
+ ];
807
+ report(
808
+ 'no-raw-fetch-in-views',
809
+ hits.length === 0,
810
+ hits.length === 0 ? '' : \`found \${hits.length} raw fetch() call(s) outside apiClient\`,
811
+ hits
812
+ );
813
+ }
814
+
815
+ // 2. no-bare-usequery-in-views — allow useQuery only inside hooks/useApi
816
+ {
817
+ const hits = walk(frontendSrc, /\\buseQuery\\s*\\(/, ['hooks/useApi', 'lib/']);
818
+ report(
819
+ 'no-bare-usequery-in-views',
820
+ hits.length === 0,
821
+ hits.length === 0 ? '' : \`found \${hits.length} bare useQuery() call(s) outside useApi\`,
822
+ hits
823
+ );
824
+ }
825
+
826
+ // 3. websocket-invalidates-cache — runtime must wire WS → invalidate
827
+ {
828
+ const candidates = [
829
+ join(root, 'node_modules', '@specverse', 'runtime', 'dist', 'runtime', 'views', 'react', 'hooks', 'useEntitySync.js'),
830
+ ];
831
+ let content = null;
832
+ for (const path of candidates) {
833
+ try { content = readFileSync(path, 'utf-8'); break; } catch { /* keep trying */ }
834
+ }
835
+ if (!content) {
836
+ report('websocket-invalidates-cache', false, 'useEntitySync hook not found in @specverse/runtime');
837
+ } else if (!/invalidateEntities/.test(content)) {
838
+ report('websocket-invalidates-cache', false, 'useEntitySync does not call invalidateEntities on event receipt');
839
+ } else {
840
+ report('websocket-invalidates-cache', true, '');
841
+ }
842
+ }
843
+
844
+ // 4. mutation-hooks-invalidate — useApi.ts must invalidate on success
845
+ {
846
+ const useApiPath = join(frontendSrc, 'hooks', 'useApi.ts');
847
+ let content = null;
848
+ try { content = readFileSync(useApiPath, 'utf-8'); } catch { /* ignore */ }
849
+ if (!content) {
850
+ report('mutation-hooks-invalidate', false, 'frontend/src/hooks/useApi.ts not found');
851
+ } else if (!/useExecuteOperationMutation/.test(content)) {
852
+ report('mutation-hooks-invalidate', false, 'useExecuteOperationMutation not exported from useApi.ts');
853
+ } else if (!/invalidateQueries/.test(content)) {
854
+ report('mutation-hooks-invalidate', false, 'useApi.ts does not call invalidateQueries on mutation success');
855
+ } else {
856
+ report('mutation-hooks-invalidate', true, '');
857
+ }
858
+ }
859
+
860
+ if (failed) {
861
+ console.log('\\n\\u2717 conformance checks failed');
862
+ process.exit(1);
863
+ }
864
+ console.log('\\n\\u2713 all conformance checks passed');
865
+ `;
866
+ writeFileSync(join(scriptsDir, 'test-conformance.mjs'), conformanceScript, 'utf-8');
867
+ console.log(` ✅ UI contract conformance: scripts/test-conformance.mjs`);
868
+ }
869
+ catch (e) {
870
+ errors.push(`UI conformance: ${e.message}`);
871
+ }
668
872
  console.log(`\n✅ All code generated in: ${outputDir}`);
669
873
  if (errors.length) {
670
874
  console.warn(`⚠️ ${errors.length} warning(s) during generation`);