@specverse/engines 4.1.22 → 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 (72) 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 +5 -2
  62. package/dist/libs/instance-factories/scaffolding/templates/generic/package-json-generator.js +10 -0
  63. package/dist/libs/instance-factories/views/templates/react/components-generator.js +34 -23
  64. package/dist/libs/instance-factories/views/templates/react/hooks-generator.js +51 -81
  65. package/dist/realize/index.d.ts.map +1 -1
  66. package/dist/realize/index.js +204 -0
  67. package/dist/realize/index.js.map +1 -1
  68. package/libs/instance-factories/applications/templates/react/api-client-generator.ts +5 -2
  69. package/libs/instance-factories/scaffolding/templates/generic/package-json-generator.ts +13 -0
  70. package/libs/instance-factories/views/templates/react/components-generator.ts +34 -23
  71. package/libs/instance-factories/views/templates/react/hooks-generator.ts +72 -88
  72. package/package.json +1 -1
@@ -192,6 +192,7 @@ function pluralize(s: string): string {
192
192
  function generateListView(name: string, model: string, lower: string, plural: string, api: string, classified: ClassifiedAttrs, belongsTo: Rel[], lifecycle: Lifecycle | null, view: any): string {
193
193
  const displayCols = classified.business.slice(0, 5);
194
194
  const statusField = lifecycle?.statusField || classified.lifecycle[0]?.name;
195
+ const apiPath = api.replace(/^\/api/, ''); // strip /api prefix for apiRequest
195
196
 
196
197
  // Build relationship lookup fetches
197
198
  const relFetches = belongsTo.map(r => {
@@ -201,7 +202,7 @@ function generateListView(name: string, model: string, lower: string, plural: st
201
202
 
202
203
  const relEffects = belongsTo.map(r => {
203
204
  const tLower = r.target.charAt(0).toLowerCase() + r.target.slice(1);
204
- return ` fetch('/api/${tLower}s').then(r => r.json()).then(data => {
205
+ return ` apiRequest('GET', '/${tLower}s').then(data => {
205
206
  if (Array.isArray(data)) {
206
207
  const m: Record<string, any> = {};
207
208
  data.forEach(e => { m[e.id] = e; });
@@ -218,6 +219,7 @@ function generateListView(name: string, model: string, lower: string, plural: st
218
219
 
219
220
  return `import { useState, useEffect } from 'react';
220
221
  import { Link } from 'react-router-dom';
222
+ import { apiRequest } from '../lib/apiClient';
221
223
  import { getEntityDisplayName } from '../lib/field-helpers';
222
224
  import { formatValue, formatDate, StatusBadge } from '../lib/view-helpers';
223
225
 
@@ -227,7 +229,7 @@ function ${name}() {
227
229
  ${relFetches}
228
230
 
229
231
  useEffect(() => {
230
- fetch('${api}').then(r => r.json()).then(data => {
232
+ apiRequest('GET', '${apiPath}').then(data => {
231
233
  setItems(Array.isArray(data) ? data : []);
232
234
  setLoading(false);
233
235
  }).catch(() => setLoading(false));
@@ -295,6 +297,7 @@ export default ${name};
295
297
 
296
298
  function generateDetailView(name: string, model: string, lower: string, plural: string, api: string, classified: ClassifiedAttrs, belongsTo: Rel[], hasMany: Rel[], lifecycle: Lifecycle | null, view: any): string {
297
299
  const statusField = lifecycle?.statusField || classified.lifecycle[0]?.name;
300
+ const apiPath = api.replace(/^\/api/, '');
298
301
 
299
302
  const relFetches = belongsTo.map(r => {
300
303
  const tLower = r.target.charAt(0).toLowerCase() + r.target.slice(1);
@@ -304,7 +307,7 @@ function generateDetailView(name: string, model: string, lower: string, plural:
304
307
  const relEffects = belongsTo.map(r => {
305
308
  const tLower = r.target.charAt(0).toLowerCase() + r.target.slice(1);
306
309
  const fk = `${r.name}_id`;
307
- return ` if (data.${fk}) fetch(\`/api/${tLower}s/\${data.${fk}}\`).then(r => r.json()).then(d => set${r.target}Ref(d)).catch(() => {});`;
310
+ return ` if (data.${fk}) apiRequest('GET', \`/${tLower}s/\${data.${fk}}\`).then(d => set${r.target}Ref(d)).catch(() => {});`;
308
311
  }).join('\n');
309
312
 
310
313
  const hasManyFetches = hasMany.map(r => {
@@ -314,11 +317,12 @@ function generateDetailView(name: string, model: string, lower: string, plural:
314
317
 
315
318
  const hasManyEffects = hasMany.map(r => {
316
319
  const tLower = r.target.charAt(0).toLowerCase() + r.target.slice(1);
317
- 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(() => {});`;
320
+ return ` apiRequest('GET', \`/${tLower}s?${lower}Id=\${data.id}\`).then(d => set${capitalize(r.name)}Items(Array.isArray(d) ? d : [])).catch(() => {});`;
318
321
  }).join('\n');
319
322
 
320
323
  return `import { useState, useEffect } from 'react';
321
324
  import { Link, useSearchParams, useNavigate } from 'react-router-dom';
325
+ import { apiRequest } from '../lib/apiClient';
322
326
  import { getEntityDisplayName } from '../lib/field-helpers';
323
327
  import { formatValue, formatDate, StatusBadge } from '../lib/view-helpers';
324
328
 
@@ -333,7 +337,7 @@ ${hasManyFetches}
333
337
 
334
338
  useEffect(() => {
335
339
  if (!id) { setLoading(false); return; }
336
- fetch(\`${api}/\${id}\`).then(r => r.ok ? r.json() : null).then(data => {
340
+ apiRequest('GET', \`${apiPath}/\${id}\`).then(data => {
337
341
  setItem(data);
338
342
  setLoading(false);
339
343
  if (data) {
@@ -348,7 +352,7 @@ ${hasManyEffects}
348
352
 
349
353
  const handleDelete = async () => {
350
354
  if (!confirm('Delete this ${lower}?')) return;
351
- await fetch(\`${api}/\${id}\`, { method: 'DELETE' });
355
+ await apiRequest('DELETE', \`${apiPath}/\${id}\`);
352
356
  navigate('/${lower}list');
353
357
  };
354
358
 
@@ -436,6 +440,7 @@ function generateFormView(name: string, model: string, lower: string, plural: st
436
440
  // Only show editable fields (business + lifecycle, not metadata/auto)
437
441
  const editableFields = [...classified.business, ...classified.lifecycle];
438
442
  const statusField = lifecycle?.statusField;
443
+ const apiPath = api.replace(/^\/api/, '');
439
444
 
440
445
  const relStates = belongsTo.map(r => {
441
446
  return ` const [${r.target.charAt(0).toLowerCase() + r.target.slice(1)}Options, set${r.target}Options] = useState<any[]>([]);`;
@@ -443,7 +448,7 @@ function generateFormView(name: string, model: string, lower: string, plural: st
443
448
 
444
449
  const relEffects = belongsTo.map(r => {
445
450
  const tLower = r.target.charAt(0).toLowerCase() + r.target.slice(1);
446
- return ` fetch('/api/${tLower}s').then(r => r.json()).then(d => set${r.target}Options(Array.isArray(d) ? d : [])).catch(() => {});`;
451
+ return ` apiRequest('GET', '/${tLower}s').then(d => set${r.target}Options(Array.isArray(d) ? d : [])).catch(() => {});`;
447
452
  }).join('\n');
448
453
 
449
454
  function inputForField(attr: Attr): string {
@@ -477,6 +482,7 @@ ${lifecycle.states.map(s => ` <option value="${s}">${s.replace(/[_-]/g,
477
482
 
478
483
  return `import { useState, useEffect } from 'react';
479
484
  import { useSearchParams, useNavigate } from 'react-router-dom';
485
+ import { apiRequest } from '../lib/apiClient';
480
486
  import { getEntityDisplayName } from '../lib/field-helpers';
481
487
 
482
488
  function ${name}() {
@@ -490,7 +496,7 @@ ${relStates}
490
496
 
491
497
  useEffect(() => {
492
498
  if (editId) {
493
- fetch(\`${api}/\${editId}\`).then(r => r.json()).then(data => setForm(data || {})).catch(() => {});
499
+ apiRequest('GET', \`${apiPath}/\${editId}\`).then(data => setForm(data || {})).catch(() => {});
494
500
  }
495
501
  ${relEffects}
496
502
  }, [editId]);
@@ -505,17 +511,12 @@ ${relEffects}
505
511
  setSaving(true);
506
512
  setError('');
507
513
  try {
508
- const method = editId ? 'PUT' : 'POST';
509
- const url = editId ? \`${api}/\${editId}\` : '${api}';
510
- const res = await fetch(url, {
511
- method,
512
- headers: { 'Content-Type': 'application/json' },
513
- body: JSON.stringify(form),
514
- });
515
- if (!res.ok) {
516
- const err = await res.json().catch(() => ({}));
517
- throw new Error(err.message || 'Save failed');
514
+ if (editId) {
515
+ await apiRequest('PUT', \`${apiPath}/\${editId}\`, form);
516
+ } else {
517
+ await apiRequest('POST', '${apiPath}', form);
518
518
  }
519
+ // apiRequest throws on non-2xx so reaching here means success.
519
520
  navigate('/${lower}list');
520
521
  } catch (err: any) {
521
522
  setError(err.message);
@@ -582,8 +583,10 @@ export default ${name};
582
583
  function generateDashboardView(name: string, model: string, lower: string, plural: string, api: string, classified: ClassifiedAttrs, view: any, modelDef: any): string {
583
584
  const numericAttrs = classified.business.filter(a => ['integer', 'number', 'money', 'decimal', 'float'].includes(a.type.toLowerCase()));
584
585
  const metricNames = numericAttrs.map(a => a.name).slice(0, 4);
586
+ const apiPath = api.replace(/^\/api/, '');
585
587
 
586
588
  return `import { useState, useEffect } from 'react';
589
+ import { apiRequest } from '../lib/apiClient';
587
590
  import { getEntityDisplayName } from '../lib/field-helpers';
588
591
  import { formatDate, StatusBadge } from '../lib/view-helpers';
589
592
 
@@ -592,7 +595,7 @@ function ${name}() {
592
595
  const [loading, setLoading] = useState(true);
593
596
 
594
597
  useEffect(() => {
595
- fetch('${api}').then(r => r.json()).then(data => {
598
+ apiRequest('GET', '${apiPath}').then(data => {
596
599
  setItems(Array.isArray(data) ? data : []);
597
600
  setLoading(false);
598
601
  }).catch(() => setLoading(false));
@@ -650,9 +653,11 @@ export default ${name};
650
653
  function generateBoardView(name: string, model: string, lower: string, plural: string, api: string, lifecycle: Lifecycle | null, view: any): string {
651
654
  const states = lifecycle?.states || ['todo', 'in_progress', 'done'];
652
655
  const statusField = lifecycle?.statusField || 'status';
656
+ const apiPath = api.replace(/^\/api/, '');
653
657
 
654
658
  return `import { useState, useEffect } from 'react';
655
659
  import { Link } from 'react-router-dom';
660
+ import { apiRequest } from '../lib/apiClient';
656
661
  import { getEntityDisplayName } from '../lib/field-helpers';
657
662
  import { statusColor } from '../lib/view-helpers';
658
663
 
@@ -661,7 +666,7 @@ function ${name}() {
661
666
  const [loading, setLoading] = useState(true);
662
667
 
663
668
  useEffect(() => {
664
- fetch('${api}').then(r => r.json()).then(data => {
669
+ apiRequest('GET', '${apiPath}').then(data => {
665
670
  setItems(Array.isArray(data) ? data : []);
666
671
  setLoading(false);
667
672
  }).catch(() => setLoading(false));
@@ -718,8 +723,10 @@ export default ${name};
718
723
  // ============================================================================
719
724
 
720
725
  function generateTimelineView(name: string, model: string, lower: string, plural: string, api: string, view: any): string {
726
+ const apiPath = api.replace(/^\/api/, '');
721
727
  return `import { useState, useEffect } from 'react';
722
728
  import { Link } from 'react-router-dom';
729
+ import { apiRequest } from '../lib/apiClient';
723
730
  import { getEntityDisplayName } from '../lib/field-helpers';
724
731
  import { formatDate, StatusBadge } from '../lib/view-helpers';
725
732
 
@@ -728,7 +735,7 @@ function ${name}() {
728
735
  const [loading, setLoading] = useState(true);
729
736
 
730
737
  useEffect(() => {
731
- fetch('${api}').then(r => r.json()).then(data => {
738
+ apiRequest('GET', '${apiPath}').then(data => {
732
739
  const sorted = (Array.isArray(data) ? data : []).sort((a, b) =>
733
740
  new Date(b.createdAt || 0).getTime() - new Date(a.createdAt || 0).getTime()
734
741
  );
@@ -778,8 +785,10 @@ function generateCalendarView(name: string, model: string, lower: string, plural
778
785
  const dateField = attrs.find(a =>
779
786
  ['startdate', 'duedate', 'scheduledat', 'eventdate', 'date'].includes(a.name.toLowerCase())
780
787
  )?.name || 'createdAt';
788
+ const apiPath = api.replace(/^\/api/, '');
781
789
 
782
790
  return `import { useState, useEffect } from 'react';
791
+ import { apiRequest } from '../lib/apiClient';
783
792
  import { getEntityDisplayName } from '../lib/field-helpers';
784
793
 
785
794
  function ${name}() {
@@ -788,7 +797,7 @@ function ${name}() {
788
797
  const [currentMonth, setCurrentMonth] = useState(new Date());
789
798
 
790
799
  useEffect(() => {
791
- fetch('${api}').then(r => r.json()).then(data => {
800
+ apiRequest('GET', '${apiPath}').then(data => {
792
801
  setItems(Array.isArray(data) ? data : []);
793
802
  setLoading(false);
794
803
  }).catch(() => setLoading(false));
@@ -852,15 +861,17 @@ export default ${name};
852
861
  function generateAnalyticsView(name: string, model: string, lower: string, plural: string, api: string, classified: ClassifiedAttrs, lifecycle: Lifecycle | null, view: any, modelDef: any): string {
853
862
  const numericAttrs = classified.business.filter(a => ['integer', 'number', 'money', 'decimal', 'float'].includes(a.type.toLowerCase()));
854
863
  const statusField = lifecycle?.statusField || 'status';
864
+ const apiPath = api.replace(/^\/api/, '');
855
865
 
856
866
  return `import { useState, useEffect } from 'react';
867
+ import { apiRequest } from '../lib/apiClient';
857
868
 
858
869
  function ${name}() {
859
870
  const [items, setItems] = useState<any[]>([]);
860
871
  const [loading, setLoading] = useState(true);
861
872
 
862
873
  useEffect(() => {
863
- fetch('${api}').then(r => r.json()).then(data => {
874
+ apiRequest('GET', '${apiPath}').then(data => {
864
875
  setItems(Array.isArray(data) ? data : []);
865
876
  setLoading(false);
866
877
  }).catch(() => setLoading(false));
@@ -1,16 +1,29 @@
1
1
  /**
2
- * React Hooks Generator
2
+ * React Hooks Generator — per-model convenience hook.
3
3
  *
4
- * Generates React custom hooks for data fetching and mutations
4
+ * Emits `use${Model}()` which is a thin delegator over the canonical
5
+ * hooks in `hooks/useApi.ts`. Historically this generator produced
6
+ * its own `useQuery(...)` + `useMutation(...)` blocks with private
7
+ * query keys (`['poll', id]`, `['polls', filters]`), which meant:
8
+ *
9
+ * 1. WebSocket events invalidating `['entities', modelName]` never
10
+ * reached views that consumed this hook.
11
+ * 2. The conformance check `no-bare-usequery-in-views` lit up for
12
+ * every per-model hook file.
13
+ *
14
+ * v2: delegate everything to `useEntitiesQuery` and
15
+ * `useExecuteOperationMutation` so there's exactly one cache key
16
+ * per model and one mutation pipeline across the whole frontend.
17
+ *
18
+ * The public API of `use${Model}()` is preserved for callers that
19
+ * depend on it: { [model], [models], isLoading, error, refetch,
20
+ * create, update, delete, isCreating, isUpdating, isDeleting }.
5
21
  */
6
22
 
7
23
  import type { TemplateContext } from '@specverse/types';
8
24
 
9
- /**
10
- * Generate React custom hook for a model
11
- */
12
25
  export default function generateReactHook(context: TemplateContext): string {
13
- const { model, controller, spec } = context;
26
+ const { model } = context;
14
27
 
15
28
  if (!model) {
16
29
  throw new Error('Model is required in template context');
@@ -19,104 +32,75 @@ export default function generateReactHook(context: TemplateContext): string {
19
32
  const modelName = model.name;
20
33
  const hookName = `use${modelName}`;
21
34
  const controllerName = `${modelName}Controller`;
35
+ const singular = modelName.charAt(0).toLowerCase() + modelName.slice(1);
36
+ const plural = singular + 's';
22
37
 
23
38
  return `/**
24
39
  * ${hookName}
25
- * Custom React hook for ${modelName} data fetching and mutations
40
+ *
41
+ * Thin delegator over the canonical useApi hooks. All state flows
42
+ * through \`['entities', '${modelName}']\` so WebSocket invalidation
43
+ * reaches every view consuming this hook.
26
44
  */
27
45
 
28
- import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
29
- import { executeOperation } from '../lib/apiClient';
46
+ import { useMemo } from 'react';
47
+ import {
48
+ useEntitiesQuery,
49
+ useExecuteOperationMutation,
50
+ } from './useApi';
30
51
  import type { ${modelName} } from '../types/${modelName}';
31
52
 
32
- interface Use${modelName}Options {
53
+ interface ${capitalize(hookName)}Options {
33
54
  id?: string;
34
55
  list?: boolean;
35
56
  filters?: Record<string, any>;
36
57
  }
37
58
 
38
- /**
39
- * ${hookName} - Fetch and mutate ${modelName} data
40
- */
41
- export function ${hookName}(options: Use${modelName}Options = {}) {
42
- const queryClient = useQueryClient();
43
- const { id, list, filters } = options;
44
-
45
- // Fetch single ${modelName}
46
- const { data: ${modelName.toLowerCase()}, isLoading: singleLoading, error: singleError } = useQuery({
47
- queryKey: ['${modelName.toLowerCase()}', id],
48
- queryFn: async () => {
49
- if (!id) return null;
50
- return await executeOperation('${controllerName}', 'retrieve', { id });
51
- },
52
- enabled: !!id && !list,
53
- });
54
-
55
- // Fetch list of ${modelName}s
56
- const { data: ${modelName.toLowerCase()}s, isLoading: listLoading, error: listError } = useQuery({
57
- queryKey: ['${modelName.toLowerCase()}s', filters],
58
- queryFn: async () => {
59
- return await executeOperation('${controllerName}', 'list', filters || {});
60
- },
61
- enabled: list,
62
- });
63
-
64
- const isLoading = list ? listLoading : singleLoading;
65
- const error = list ? listError : singleError;
66
-
67
- // Create mutation
68
- const createMutation = useMutation({
69
- mutationFn: async (data: Partial<${modelName}>) => {
70
- return await executeOperation('${controllerName}', 'create', data);
71
- },
72
- onSuccess: () => {
73
- queryClient.invalidateQueries({ queryKey: ['${modelName.toLowerCase()}s'] });
74
- },
75
- });
76
-
77
- // Update mutation
78
- const updateMutation = useMutation({
79
- mutationFn: async ({ id, data }: { id: string; data: Partial<${modelName}> }) => {
80
- return await executeOperation('${controllerName}', 'update', { id, ...data });
81
- },
82
- onSuccess: (_, variables) => {
83
- queryClient.invalidateQueries({ queryKey: ['${modelName.toLowerCase()}', variables.id] });
84
- queryClient.invalidateQueries({ queryKey: ['${modelName.toLowerCase()}s'] });
85
- },
86
- });
87
-
88
- // Delete mutation
89
- const deleteMutation = useMutation({
90
- mutationFn: async (id: string) => {
91
- return await executeOperation('${controllerName}', 'delete', { id });
92
- },
93
- onSuccess: () => {
94
- queryClient.invalidateQueries({ queryKey: ['${modelName.toLowerCase()}s'] });
95
- },
96
- });
97
-
98
- // Refetch functions
99
- const refetch = () => {
100
- if (list) {
101
- queryClient.invalidateQueries({ queryKey: ['${modelName.toLowerCase()}s'] });
102
- } else if (id) {
103
- queryClient.invalidateQueries({ queryKey: ['${modelName.toLowerCase()}', id] });
59
+ export function ${hookName}(opts: ${capitalize(hookName)}Options = {}) {
60
+ const { id, filters: _filters } = opts;
61
+
62
+ // Canonical list query one cache key per model. The list flag
63
+ // is preserved for backwards compatibility but the query always
64
+ // runs; React Query dedupes across consumers automatically.
65
+ const query = useEntitiesQuery('${controllerName}', '${modelName}');
66
+ const ${plural} = (query.data as ${modelName}[] | undefined) || [];
67
+
68
+ // Single-entity lookup is client-side against the canonical list
69
+ // so both the detail view and the list view share one source of
70
+ // truth. If the caller needs server-side hydration for a single
71
+ // entity, they should call useEntitiesQuery directly with a
72
+ // filtered controller endpoint.
73
+ const ${singular} = useMemo<${modelName} | null>(() => {
74
+ if (!id) return null;
75
+ for (const entity of ${plural}) {
76
+ const entityId = (entity as any)?.id || (entity as any)?.data?.id;
77
+ if (entityId === id) return entity;
104
78
  }
105
- };
79
+ return null;
80
+ }, [id, ${plural}]);
81
+
82
+ const mutation = useExecuteOperationMutation();
106
83
 
107
84
  return {
108
- ${modelName.toLowerCase()},
109
- ${modelName.toLowerCase()}s,
110
- isLoading,
111
- error,
112
- refetch,
113
- create: createMutation.mutateAsync,
114
- update: updateMutation.mutateAsync,
115
- delete: deleteMutation.mutateAsync,
116
- isCreating: createMutation.isPending,
117
- isUpdating: updateMutation.isPending,
118
- isDeleting: deleteMutation.isPending,
85
+ ${singular},
86
+ ${plural},
87
+ isLoading: query.isLoading,
88
+ error: query.error,
89
+ refetch: () => { /* handled by useEntitiesQuery refetchInterval + WS invalidation */ },
90
+ create: (data: Partial<${modelName}>) =>
91
+ mutation.mutateAsync({ controllerName: '${controllerName}', operationName: 'create', data: data as Record<string, unknown> }),
92
+ update: (args: { id: string; data: Partial<${modelName}> }) =>
93
+ mutation.mutateAsync({ controllerName: '${controllerName}', operationName: 'update', entityId: args.id, data: args.data as Record<string, unknown> }),
94
+ delete: (entityId: string) =>
95
+ mutation.mutateAsync({ controllerName: '${controllerName}', operationName: 'delete', entityId, data: {} }),
96
+ isCreating: mutation.isPending,
97
+ isUpdating: mutation.isPending,
98
+ isDeleting: mutation.isPending,
119
99
  };
120
100
  }
121
101
  `;
122
102
  }
103
+
104
+ function capitalize(s: string): string {
105
+ return s.charAt(0).toUpperCase() + s.slice(1);
106
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specverse/engines",
3
- "version": "4.1.22",
3
+ "version": "4.1.23",
4
4
  "description": "SpecVerse toolchain — parser, inference, realize, generators, AI, registry",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",