@specverse/engines 4.2.2 → 4.3.0

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 (54) hide show
  1. package/dist/inference/core/specly-converter.d.ts.map +1 -1
  2. package/dist/inference/core/specly-converter.js +43 -22
  3. package/dist/inference/core/specly-converter.js.map +1 -1
  4. package/dist/inference/index.d.ts.map +1 -1
  5. package/dist/inference/index.js +29 -0
  6. package/dist/inference/index.js.map +1 -1
  7. package/dist/libs/instance-factories/applications/templates/react/index-html-generator.js +2 -2
  8. package/dist/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.js +291 -59
  9. package/dist/libs/instance-factories/applications/templates/react-starter/belongs-to.js +37 -0
  10. package/dist/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.js +9 -124
  11. package/dist/libs/instance-factories/applications/templates/react-starter/detail-body-composer.js +9 -75
  12. package/dist/libs/instance-factories/applications/templates/react-starter/emit-jsx-source.js +129 -0
  13. package/dist/libs/instance-factories/applications/templates/react-starter/form-body-composer.js +9 -212
  14. package/dist/libs/instance-factories/applications/templates/react-starter/helpers-emitter.js +260 -5
  15. package/dist/libs/instance-factories/applications/templates/react-starter/list-body-composer.js +8 -50
  16. package/dist/libs/instance-factories/applications/templates/react-starter/operation-emitters.js +470 -0
  17. package/dist/libs/instance-factories/applications/templates/react-starter/operation-view-generator.js +136 -0
  18. package/dist/libs/instance-factories/applications/templates/react-starter/package-json-generator.js +2 -1
  19. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +54 -61
  20. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +115 -27
  21. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +17 -9
  22. package/dist/libs/instance-factories/applications/templates/react-starter/use-api-hooks-starter-generator.js +49 -10
  23. package/dist/libs/instance-factories/applications/templates/react-starter/view-emitter.js +223 -10
  24. package/dist/libs/instance-factories/applications/templates/react-starter/views-generator.js +14 -1
  25. package/dist/libs/instance-factories/controllers/templates/fastify/routes-generator.js +13 -1
  26. package/dist/libs/instance-factories/controllers/templates/fastify/server-generator.js +18 -0
  27. package/libs/instance-factories/applications/templates/react/index-html-generator.ts +2 -2
  28. package/libs/instance-factories/applications/templates/react-starter/__tests__/dashboard-body-composer.test.ts +3 -1
  29. package/libs/instance-factories/applications/templates/react-starter/__tests__/detail-body-composer.test.ts +24 -25
  30. package/libs/instance-factories/applications/templates/react-starter/__tests__/list-body-composer.test.ts +3 -3
  31. package/libs/instance-factories/applications/templates/react-starter/__tests__/orchestrator.test.ts +2 -0
  32. package/libs/instance-factories/applications/templates/react-starter/__tests__/parity-p3-rendered-output.test.ts +1 -1
  33. package/libs/instance-factories/applications/templates/react-starter/__tests__/starter-generators.test.ts +5 -4
  34. package/libs/instance-factories/applications/templates/react-starter/__tests__/views-generator.test.ts +11 -4
  35. package/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.ts +377 -71
  36. package/libs/instance-factories/applications/templates/react-starter/belongs-to.ts +66 -0
  37. package/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.ts +15 -182
  38. package/libs/instance-factories/applications/templates/react-starter/detail-body-composer.ts +16 -128
  39. package/libs/instance-factories/applications/templates/react-starter/emit-jsx-source.ts +233 -0
  40. package/libs/instance-factories/applications/templates/react-starter/form-body-composer.ts +16 -376
  41. package/libs/instance-factories/applications/templates/react-starter/helpers-emitter.ts +282 -4
  42. package/libs/instance-factories/applications/templates/react-starter/list-body-composer.ts +26 -135
  43. package/libs/instance-factories/applications/templates/react-starter/operation-emitters.ts +497 -0
  44. package/libs/instance-factories/applications/templates/react-starter/operation-view-generator.ts +209 -0
  45. package/libs/instance-factories/applications/templates/react-starter/package-json-generator.ts +1 -0
  46. package/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +54 -61
  47. package/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +115 -27
  48. package/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +17 -9
  49. package/libs/instance-factories/applications/templates/react-starter/use-api-hooks-starter-generator.ts +71 -10
  50. package/libs/instance-factories/applications/templates/react-starter/view-emitter.ts +359 -18
  51. package/libs/instance-factories/applications/templates/react-starter/views-generator.ts +28 -1
  52. package/libs/instance-factories/controllers/templates/fastify/routes-generator.ts +13 -1
  53. package/libs/instance-factories/controllers/templates/fastify/server-generator.ts +18 -0
  54. package/package.json +2 -2
@@ -4,93 +4,86 @@
4
4
  * Safe to edit. Edits are preserved across regeneration via content
5
5
  * hashing (see .specverse-gen/hashes.json). To accept an upstream
6
6
  * regeneration of this file, delete it first, then run `spv realize`.
7
+ *
8
+ * Inspection-only view: entity picker + rendered fields + related lists.
9
+ * All mutations (create, update, delete, evolve) live in the form view.
10
+ * To edit/delete, navigate to the form via the sidebar.
7
11
  */
8
- import { use{{PLURAL_MODEL}}Query, useDelete{{MODEL_NAME}}Mutation } from '../hooks/useApi';
12
+ {{RELATED_TAB_IMPORT}}
13
+ import { use{{PLURAL_MODEL}}Query } from '../hooks/useApi';
9
14
  {{RELATED_IMPORTS}}
10
15
  import type { {{MODEL_NAME}} } from '../types/api';
11
16
 
12
17
  interface {{MODEL_NAME}}DetailViewProps {
13
- entityId: string | number;
14
- onEdit?: (item: {{MODEL_NAME}}) => void;
15
- onBack?: () => void;
16
- /** Called after the delete mutation succeeds. */
17
- onDeleted?: () => void;
18
+ /** Initial selection. When absent, the first entity in the list is shown. */
19
+ entityId?: string | number;
20
+ /** Called when the user picks a different entity from the picker. */
21
+ onEntityChange?: (id: string | number) => void;
22
+ /** Called when the user clicks a resolved belongsTo cell or a related-list
23
+ * row. Wired by App.tsx to navigate to the target model's detail view. */
24
+ onNavigate?: (model: string, id: string | number) => void;
18
25
  }
19
26
 
20
27
  export function {{MODEL_NAME}}DetailView({
21
28
  entityId,
22
- onEdit,
23
- onBack,
24
- onDeleted,
29
+ onEntityChange,
30
+ onNavigate,
25
31
  }: {{MODEL_NAME}}DetailViewProps) {
32
+ // onNavigate is referenced from walker-emitted FK cells + related-row
33
+ // clicks below; reference it here so TS doesn't flag it unused for
34
+ // models without belongsTo or hasMany.
35
+ void onNavigate;
26
36
  const { data: items = [], isLoading, error } = use{{PLURAL_MODEL}}Query();
27
- const deleteItem = useDelete{{MODEL_NAME}}Mutation();
28
37
  {{RELATED_HOOKS}}
38
+ {{RELATED_TAB_STATE}}
29
39
 
40
+ // Resolve which entity to display: explicit prop wins, else first item.
41
+ const activeId = entityId ?? (items[0] as any)?.id;
30
42
  const item = items.find(
31
- (x: {{MODEL_NAME}}) => (x as any).id === entityId
43
+ (x: {{MODEL_NAME}}) => (x as any).id === activeId
32
44
  );
33
45
 
34
46
  if (isLoading) return <div className="p-4 text-gray-500">Loading…</div>;
35
47
  if (error) return <div className="p-4 text-red-600">Error loading {{PLURAL_LOWER}}: {String(error)}</div>;
36
- if (!item) return <div className="p-4 text-gray-400">No {{SINGULAR_LOWER}} matching id {String(entityId)}.</div>;
37
-
38
- const handleDelete = async () => {
39
- if (!confirm('Delete this {{SINGULAR_LOWER}}?')) return;
40
- try {
41
- await deleteItem.mutateAsync((item as any).id);
42
- onDeleted?.();
43
- } catch {
44
- // deleteItem.error is surfaced below
45
- }
46
- };
47
48
 
48
49
  return (
49
50
  <div className="p-6 space-y-4">
50
- <div className="flex items-center justify-between">
51
- <div className="flex items-center gap-3">
52
- {onBack && (
53
- <button
54
- type="button"
55
- onClick={onBack}
56
- className="text-sm text-gray-500 hover:text-gray-700"
57
- >
58
- ← Back
59
- </button>
60
- )}
61
- <h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
62
- {{MODEL_NAME}} detail
63
- </h2>
64
- </div>
65
- <div className="flex gap-2">
66
- {onEdit && (
67
- <button
68
- type="button"
69
- onClick={() => onEdit(item)}
70
- className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
71
- >
72
- Edit
73
- </button>
51
+ <div className="flex items-center gap-3">
52
+ <label
53
+ htmlFor="{{SINGULAR_LOWER}}-picker"
54
+ className="text-sm font-medium text-gray-700 dark:text-gray-300"
55
+ >
56
+ Select {{MODEL_NAME}}:
57
+ </label>
58
+ <select
59
+ id="{{SINGULAR_LOWER}}-picker"
60
+ value={activeId == null ? '' : String(activeId)}
61
+ onChange={e => onEntityChange?.(e.target.value)}
62
+ className="flex-1 max-w-xl rounded border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
63
+ >
64
+ {items.length === 0 && (
65
+ <option value="">No {{PLURAL_LOWER}} yet</option>
74
66
  )}
75
- <button
76
- type="button"
77
- onClick={handleDelete}
78
- disabled={deleteItem.isPending}
79
- className="rounded bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-60"
80
- >
81
- {deleteItem.isPending ? 'Deleting…' : 'Delete'}
82
- </button>
83
- </div>
67
+ {items.map((x: {{MODEL_NAME}}) => (
68
+ <option key={String((x as any).id)} value={String((x as any).id)}>
69
+ {getEntityDisplayName(x)}
70
+ </option>
71
+ ))}
72
+ </select>
84
73
  </div>
85
74
 
86
- {/* Pattern-rendered detail body (fields). Edit freely. */}
87
- {{BODY}}
88
-
89
- {deleteItem.isError && (
90
- <div className="p-2 text-sm text-red-600">
91
- Delete failed: {String(deleteItem.error)}
75
+ {!item && (
76
+ <div className="rounded-lg border border-gray-200 bg-white p-6 text-sm text-gray-400 dark:border-gray-700 dark:bg-gray-900">
77
+ No {{SINGULAR_LOWER}} to display.
92
78
  </div>
93
79
  )}
80
+
81
+ {item && (
82
+ <>
83
+ {/* Pattern-rendered detail body (fields + related). Edit freely. */}
84
+ {{BODY}}
85
+ </>
86
+ )}
94
87
  </div>
95
88
  );
96
89
  }
@@ -6,36 +6,56 @@
6
6
  * regeneration of this file, delete it first, then run `spv realize`.
7
7
  *
8
8
  * Controlled form. One field per non-auto-generated attribute.
9
- * Mode: 'create' (default) or 'update' passed as a prop.
9
+ * Mode: 'create' (default) or 'update'. When the model has one or
10
+ * more lifecycles AND mode = 'update', an "Evolve" tab appears
11
+ * alongside "Edit" that drives a state transition via PATCH /:id/evolve.
10
12
  */
11
13
  import { useEffect, useState } from 'react';
12
14
  import {
13
15
  use{{PLURAL_MODEL}}Query,
14
16
  useCreate{{MODEL_NAME}}Mutation,
15
17
  useUpdate{{MODEL_NAME}}Mutation,
18
+ useDelete{{MODEL_NAME}}Mutation,
19
+ {{LIFECYCLE_IMPORTS}}
16
20
  } from '../hooks/useApi';
17
21
  {{RELATED_IMPORTS}}
18
22
  import type { {{MODEL_NAME}} } from '../types/api';
23
+ import { {{MODEL_NAME}}ListView } from './{{MODEL_NAME}}ListView';
19
24
 
20
25
  type FormMode = 'create' | 'update';
26
+ type ActiveTab = 'edit' | 'evolve';
21
27
 
22
28
  interface {{MODEL_NAME}}FormViewProps {
23
29
  mode?: FormMode;
24
30
  /** Required in update mode. */
25
31
  entityId?: string | number;
26
32
  onSuccess?: (item: {{MODEL_NAME}}) => void;
27
- onCancel?: () => void;
33
+ /** Called after the delete mutation succeeds. Wired by App.tsx to
34
+ * navigate back to the list view. */
35
+ onDeleted?: () => void;
36
+ /** Called when the user clicks a row in the embedded
37
+ * {{MODEL_NAME}}ListView below the form. Wired by App.tsx to
38
+ * re-navigate to this form with `entityId` set, so clicking a
39
+ * row loads that entity's data into the form for update. */
40
+ onSelectEntity?: (item: {{MODEL_NAME}}) => void;
28
41
  }
29
42
 
30
43
  export function {{MODEL_NAME}}FormView({
31
44
  mode = 'create',
32
45
  entityId,
33
46
  onSuccess,
34
- onCancel,
47
+ onDeleted,
48
+ onSelectEntity,
35
49
  }: {{MODEL_NAME}}FormViewProps) {
50
+ // Referenced from the walker-emitted <{{MODEL_NAME}}ListView
51
+ // onSelect={onSelectEntity} /> below; void it here so strict TS
52
+ // doesn't flag it when body positioning elides the reference.
53
+ void onSelectEntity;
36
54
  const { data: items = [] } = use{{PLURAL_MODEL}}Query();
37
55
  const createItem = useCreate{{MODEL_NAME}}Mutation();
38
56
  const updateItem = useUpdate{{MODEL_NAME}}Mutation();
57
+ const deleteItem = useDelete{{MODEL_NAME}}Mutation();
58
+ {{LIFECYCLE_HOOK}}
39
59
  {{RELATED_HOOKS}}
40
60
 
41
61
  const existing =
@@ -44,6 +64,8 @@ export function {{MODEL_NAME}}FormView({
44
64
  : undefined;
45
65
 
46
66
  const [formData, setFormData] = useState<Partial<{{MODEL_NAME}}>>(existing ?? {});
67
+ const [activeTab, setActiveTab] = useState<ActiveTab>('edit');
68
+ {{LIFECYCLE_STATE}}
47
69
 
48
70
  // When the fetched list lands after initial render (update mode),
49
71
  // hydrate the form with the loaded entity's data.
@@ -55,6 +77,21 @@ export function {{MODEL_NAME}}FormView({
55
77
  setFormData(prev => ({ ...prev, [field]: value }) as Partial<{{MODEL_NAME}}>);
56
78
  };
57
79
 
80
+ const handleClear = () => {
81
+ setFormData(existing ?? {});
82
+ };
83
+
84
+ const handleDelete = async () => {
85
+ if (mode !== 'update' || entityId == null) return;
86
+ if (!confirm('Delete this {{SINGULAR_LOWER}}?')) return;
87
+ try {
88
+ await deleteItem.mutateAsync(entityId as any);
89
+ onDeleted?.();
90
+ } catch {
91
+ // deleteItem.error is surfaced below
92
+ }
93
+ };
94
+
58
95
  const handleSubmit = async (event: React.FormEvent) => {
59
96
  event.preventDefault();
60
97
  try {
@@ -71,46 +108,97 @@ export function {{MODEL_NAME}}FormView({
71
108
  }
72
109
  };
73
110
 
111
+ {{LIFECYCLE_HANDLER}}
112
+
74
113
  const mutation = mode === 'create' ? createItem : updateItem;
75
114
  const submitLabel =
76
115
  mode === 'create'
77
116
  ? mutation.isPending ? 'Creating…' : 'Create {{MODEL_NAME}}'
78
117
  : mutation.isPending ? 'Updating…' : 'Update {{MODEL_NAME}}';
79
118
 
119
+ const hasLifecycles = {{HAS_LIFECYCLES}};
120
+ const showTabs = mode === 'update' && hasLifecycles;
121
+
80
122
  return (
81
- <form onSubmit={handleSubmit} className="p-6 space-y-6">
123
+ <div className="p-6 space-y-6">
82
124
  <h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
83
125
  {mode === 'create' ? 'New {{MODEL_NAME}}' : 'Edit {{MODEL_NAME}}'}
84
126
  </h2>
85
127
 
86
- {/* Pattern-rendered form fields. Edit freely. */}
87
- {{BODY}}
88
-
89
- <div className="flex gap-2 border-t border-gray-200 dark:border-gray-700 pt-4">
90
- <button
91
- type="submit"
92
- disabled={mutation.isPending}
93
- className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-60"
94
- >
95
- {submitLabel}
96
- </button>
97
- {onCancel && (
128
+ {showTabs && (
129
+ <div className="flex gap-1 border-b border-gray-200 dark:border-gray-700">
98
130
  <button
99
131
  type="button"
100
- onClick={onCancel}
101
- className="rounded border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-800"
132
+ onClick={() => setActiveTab('edit')}
133
+ className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${activeTab === 'edit' ? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400' : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'}`}
102
134
  >
103
- Cancel
135
+ Edit
136
+ </button>
137
+ <button
138
+ type="button"
139
+ onClick={() => setActiveTab('evolve')}
140
+ className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${activeTab === 'evolve' ? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400' : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'}`}
141
+ >
142
+ Evolve
104
143
  </button>
105
- )}
106
- </div>
107
-
108
- {mutation.isError && (
109
- <div className="rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300">
110
- {mode === 'create' ? 'Create failed: ' : 'Update failed: '}
111
- {String(mutation.error)}
112
144
  </div>
113
145
  )}
114
- </form>
146
+
147
+ {(!showTabs || activeTab === 'edit') && (
148
+ <>
149
+ <form onSubmit={handleSubmit} className="space-y-6">
150
+ {/* Pattern-rendered form fields. Edit freely. */}
151
+ {{FIELDS_BODY}}
152
+
153
+ <div className="flex gap-2">
154
+ <button
155
+ type="submit"
156
+ disabled={mutation.isPending}
157
+ className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-60"
158
+ >
159
+ {submitLabel}
160
+ </button>
161
+ {mode === 'update' && (
162
+ <button
163
+ type="button"
164
+ onClick={handleDelete}
165
+ disabled={deleteItem.isPending}
166
+ className="rounded bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-60"
167
+ >
168
+ {deleteItem.isPending ? 'Deleting…' : 'Delete'}
169
+ </button>
170
+ )}
171
+ <button
172
+ type="button"
173
+ onClick={handleClear}
174
+ disabled={mutation.isPending}
175
+ className="rounded border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 disabled:opacity-60 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-800"
176
+ >
177
+ Clear
178
+ </button>
179
+ </div>
180
+
181
+ {mutation.isError && (
182
+ <div className="rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300">
183
+ {mode === 'create' ? 'Create failed: ' : 'Update failed: '}
184
+ {String(mutation.error)}
185
+ </div>
186
+ )}
187
+
188
+ {deleteItem.isError && (
189
+ <div className="rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300">
190
+ Delete failed: {String(deleteItem.error)}
191
+ </div>
192
+ )}
193
+ </form>
194
+
195
+ {/* Existing entities list sits outside the <form> so its
196
+ search input and inner interactions don't submit the form. */}
197
+ {{EXISTING_LIST_BODY}}
198
+ </>
199
+ )}
200
+
201
+ {{LIFECYCLE_PANEL}}
202
+ </div>
115
203
  );
116
204
  }
@@ -13,9 +13,15 @@ import type { {{MODEL_NAME}} } from '../types/api';
13
13
  interface {{MODEL_NAME}}ListViewProps {
14
14
  onSelect?: (item: {{MODEL_NAME}}) => void;
15
15
  onCreate?: () => void;
16
+ /** Called when the user clicks a resolved belongsTo cell.
17
+ * Wired by App.tsx to switch to the target model's detail view. */
18
+ onNavigate?: (model: string, id: string | number) => void;
16
19
  }
17
20
 
18
- export function {{MODEL_NAME}}ListView({ onSelect, onCreate }: {{MODEL_NAME}}ListViewProps) {
21
+ export function {{MODEL_NAME}}ListView({ onSelect, onCreate, onNavigate }: {{MODEL_NAME}}ListViewProps) {
22
+ // onNavigate is referenced from walker-emitted FK cells below; reference
23
+ // it here so TS doesn't flag it as unused for models without belongsTo.
24
+ void onNavigate;
19
25
  const { data: items = [], isLoading, error } = use{{PLURAL_MODEL}}Query();
20
26
  const deleteItem = useDelete{{MODEL_NAME}}Mutation();
21
27
  {{RELATED_HOOKS}}
@@ -46,15 +52,17 @@ export function {{MODEL_NAME}}ListView({ onSelect, onCreate }: {{MODEL_NAME}}Lis
46
52
  placeholder="Search {{PLURAL_LOWER}}…"
47
53
  value={searchTerm}
48
54
  onChange={e => setSearchTerm(e.target.value)}
49
- className="w-64 rounded border border-gray-300 px-3 py-2 text-sm"
55
+ className="w-64 rounded border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
50
56
  />
51
- <button
52
- type="button"
53
- onClick={onCreate}
54
- className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
55
- >
56
- + New {{MODEL_NAME}}
57
- </button>
57
+ {onCreate && (
58
+ <button
59
+ type="button"
60
+ onClick={onCreate}
61
+ className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
62
+ >
63
+ + New {{MODEL_NAME}}
64
+ </button>
65
+ )}
58
66
  </div>
59
67
 
60
68
  {/* Pattern-rendered list body (table + rows). Edit freely. */}
@@ -3,6 +3,21 @@ function pluralize(s) {
3
3
  if (/(s|x|z|ch|sh)$/.test(s)) return s + "es";
4
4
  return s + "s";
5
5
  }
6
+ function extractLifecycles(model) {
7
+ const out = [];
8
+ const lifecycles = model?.lifecycles ?? {};
9
+ for (const [name, def] of Object.entries(lifecycles)) {
10
+ if (!def || typeof def !== "object") continue;
11
+ let states = [];
12
+ if (Array.isArray(def.states)) {
13
+ states = def.states.map((s) => typeof s === "string" ? s : s?.name ?? s?.id ?? "").filter(Boolean);
14
+ } else if (typeof def.flow === "string") {
15
+ states = def.flow.split(/\s*(?:->|,)\s*/).map((s) => s.trim()).filter(Boolean);
16
+ }
17
+ if (states.length > 0) out.push({ name, states });
18
+ }
19
+ return out;
20
+ }
6
21
  async function generate(context) {
7
22
  const models = Object.keys(context.spec.models ?? {});
8
23
  const importsAndTypes = `/**
@@ -20,10 +35,13 @@ ${models.map((m) => `import type { ${m} } from '../types/api';`).join("\n")}
20
35
  const API_BASE = import.meta.env.VITE_API_BASE_URL || '';
21
36
 
22
37
  async function fetchJSON<T = unknown>(url: string, init?: RequestInit): Promise<T> {
23
- const res = await fetch(url, {
24
- headers: { 'Content-Type': 'application/json' },
25
- ...init,
26
- });
38
+ // Only declare Content-Type: application/json when there's actually a
39
+ // body. Fastify's default JSON parser rejects empty-body requests that
40
+ // claim JSON (FST_ERR_CTP_EMPTY_JSON_BODY) \u2014 so DELETE / GET without
41
+ // bodies must NOT send the header.
42
+ const headers: Record<string, string> = {};
43
+ if (init?.body != null) headers['Content-Type'] = 'application/json';
44
+ const res = await fetch(url, { ...init, headers: { ...headers, ...init?.headers } });
27
45
  if (!res.ok) {
28
46
  throw new Error(\`\${res.status} \${res.statusText} \u2014 \${url}\`);
29
47
  }
@@ -31,15 +49,36 @@ async function fetchJSON<T = unknown>(url: string, init?: RequestInit): Promise<
31
49
  return (await res.json()) as T;
32
50
  }
33
51
  `;
34
- const hookBlocks = models.map((m) => generateModelHooks(m)).join("\n\n");
52
+ const hookBlocks = models.map((m) => generateModelHooks(m, context.spec.models?.[m])).join("\n\n");
35
53
  return importsAndTypes + "\n" + hookBlocks + "\n";
36
54
  }
37
- function generateModelHooks(model) {
55
+ function generateModelHooks(model, modelDef) {
38
56
  const plural = pluralize(model);
39
57
  const resource = plural.toLowerCase();
40
- const modelLower = model.toLowerCase();
58
+ const lifecycles = extractLifecycles(modelDef);
59
+ const hasLifecycle = lifecycles.length > 0;
60
+ const lifecycleBlock = hasLifecycle ? `
61
+ export const ${model.toUpperCase()}_LIFECYCLES = ${JSON.stringify(
62
+ Object.fromEntries(lifecycles.map((l) => [l.name, { states: l.states }])),
63
+ null,
64
+ 2
65
+ )} as const;
66
+ ` : "";
67
+ const evolveHook = hasLifecycle ? `
68
+ export function useEvolve${model}Mutation() {
69
+ const qc = useQueryClient();
70
+ return useMutation({
71
+ mutationFn: ({ id, toState, lifecycleName }: { id: string | number; toState: string; lifecycleName?: string }) =>
72
+ fetchJSON<${model}>(\`\${API_BASE}/api/${resource}/\${id}/evolve\`, {
73
+ method: 'PATCH',
74
+ body: JSON.stringify({ toState, lifecycleName }),
75
+ }),
76
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['${resource}'] }),
77
+ });
78
+ }
79
+ ` : "";
41
80
  return `// \u2500\u2500\u2500 ${model} \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
42
-
81
+ ${lifecycleBlock}
43
82
  export function use${plural}Query() {
44
83
  return useQuery({
45
84
  queryKey: ['${resource}'],
@@ -72,7 +111,7 @@ export function useUpdate${model}Mutation() {
72
111
  return useMutation({
73
112
  mutationFn: ({ id, data }: { id: string | number; data: Partial<${model}> }) =>
74
113
  fetchJSON<${model}>(\`\${API_BASE}/api/${resource}/\${id}\`, {
75
- method: 'PATCH',
114
+ method: 'PUT',
76
115
  body: JSON.stringify(data),
77
116
  }),
78
117
  onSuccess: () => qc.invalidateQueries({ queryKey: ['${resource}'] }),
@@ -86,7 +125,7 @@ export function useDelete${model}Mutation() {
86
125
  fetchJSON<void>(\`\${API_BASE}/api/${resource}/\${id}\`, { method: 'DELETE' }),
87
126
  onSuccess: () => qc.invalidateQueries({ queryKey: ['${resource}'] }),
88
127
  });
89
- }`;
128
+ }${evolveHook}`;
90
129
  }
91
130
  var stdin_default = generate;
92
131
  export {