mvc-kit 2.0.0 → 2.1.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.
- package/README.md +19 -0
- package/agent-config/bin/setup.mjs +217 -0
- package/agent-config/claude-code/.claude-plugin/plugin.json +5 -0
- package/agent-config/claude-code/agents/mvc-kit-architect.md +97 -0
- package/agent-config/claude-code/skills/guide/SKILL.md +85 -0
- package/agent-config/claude-code/skills/guide/anti-patterns.md +321 -0
- package/agent-config/claude-code/skills/guide/api-reference.md +310 -0
- package/agent-config/claude-code/skills/guide/patterns.md +336 -0
- package/agent-config/claude-code/skills/review/SKILL.md +53 -0
- package/agent-config/claude-code/skills/review/checklist.md +89 -0
- package/agent-config/claude-code/skills/scaffold/SKILL.md +52 -0
- package/agent-config/claude-code/skills/scaffold/templates/channel.md +88 -0
- package/agent-config/claude-code/skills/scaffold/templates/collection.md +49 -0
- package/agent-config/claude-code/skills/scaffold/templates/controller.md +56 -0
- package/agent-config/claude-code/skills/scaffold/templates/eventbus.md +54 -0
- package/agent-config/claude-code/skills/scaffold/templates/model.md +102 -0
- package/agent-config/claude-code/skills/scaffold/templates/page-component.md +58 -0
- package/agent-config/claude-code/skills/scaffold/templates/service.md +101 -0
- package/agent-config/claude-code/skills/scaffold/templates/viewmodel.md +128 -0
- package/agent-config/copilot/copilot-instructions.md +242 -0
- package/agent-config/cursor/cursorrules +242 -0
- package/package.json +6 -2
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
# mvc-kit Prescribed Patterns
|
|
2
|
+
|
|
3
|
+
These are mandatory patterns. All code using mvc-kit must follow them.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## State Design: Source of Truth Only
|
|
8
|
+
|
|
9
|
+
State holds only raw values — user inputs and server data. Never derived values, never loading/error flags.
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
// BAD
|
|
13
|
+
interface State {
|
|
14
|
+
items: Item[];
|
|
15
|
+
filtered: Item[]; // derived — use a getter
|
|
16
|
+
loading: boolean; // use vm.async
|
|
17
|
+
error: string | null; // use vm.async
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// GOOD
|
|
21
|
+
interface State {
|
|
22
|
+
items: Item[];
|
|
23
|
+
search: string;
|
|
24
|
+
typeFilter: 'all' | 'office' | 'warehouse';
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Computed Getters
|
|
31
|
+
|
|
32
|
+
TypeScript `get` accessors compute derived values from state. Auto-memoized after `init()`.
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
class LocationsViewModel extends ViewModel<State> {
|
|
36
|
+
get filtered(): Item[] {
|
|
37
|
+
const { items, search, typeFilter } = this.state;
|
|
38
|
+
let result = items;
|
|
39
|
+
if (search) {
|
|
40
|
+
const q = search.toLowerCase();
|
|
41
|
+
result = result.filter(i => i.name.toLowerCase().includes(q));
|
|
42
|
+
}
|
|
43
|
+
if (typeFilter !== 'all') {
|
|
44
|
+
result = result.filter(i => i.type === typeFilter);
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
get total(): number { return this.state.items.length; }
|
|
50
|
+
get hasResults(): boolean { return this.filtered.length > 0; }
|
|
51
|
+
get isEmpty(): boolean { return this.total > 0 && !this.hasResults; }
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Never call `set()` inside a getter.** Dev mode detects this.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## One-Liner Setters
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
setSearch(search: string) { this.set({ search }); }
|
|
63
|
+
setTypeFilter(typeFilter: State['typeFilter']) { this.set({ typeFilter }); }
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The setter changes state → React re-renders → getters recompute. No manual refiltering needed.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Encapsulating Collections
|
|
71
|
+
|
|
72
|
+
ViewModels subscribe internally. Components never see Collections.
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
class LocationsViewModel extends ViewModel<State> {
|
|
76
|
+
private collection = singleton(LocationsCollection);
|
|
77
|
+
private service = singleton(LocationService);
|
|
78
|
+
|
|
79
|
+
protected onInit() {
|
|
80
|
+
this.subscribeTo(this.collection, () => {
|
|
81
|
+
this.set({ items: this.collection.items });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Smart init: use cached data or fetch fresh
|
|
85
|
+
if (this.collection.length > 0) {
|
|
86
|
+
this.set({ items: this.collection.items });
|
|
87
|
+
} else {
|
|
88
|
+
this.load();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Async Methods — Happy Path Only
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
async load() {
|
|
100
|
+
const data = await this.service.getAll(this.disposeSignal);
|
|
101
|
+
this.collection.reset(data);
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
No try/catch. No `set({ loading: true })`. No AbortError check. The framework handles it.
|
|
106
|
+
|
|
107
|
+
**Only add try/catch** for imperative events on error:
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
async save() {
|
|
111
|
+
try {
|
|
112
|
+
const result = await this.service.save(this.state.draft, this.disposeSignal);
|
|
113
|
+
this.collection.update(result.id, result);
|
|
114
|
+
this.emit('saved', { id: result.id });
|
|
115
|
+
} catch (e) {
|
|
116
|
+
if (!isAbortError(e)) {
|
|
117
|
+
this.emit('error', { message: classifyError(e).message });
|
|
118
|
+
}
|
|
119
|
+
throw e; // MUST re-throw so async tracking captures it
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## Component Pattern
|
|
127
|
+
|
|
128
|
+
```tsx
|
|
129
|
+
function LocationsPage() {
|
|
130
|
+
const [state, vm] = useLocal(LocationsViewModel, {
|
|
131
|
+
items: [], search: '', typeFilter: 'all',
|
|
132
|
+
});
|
|
133
|
+
const { loading, error } = vm.async.load;
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<div>
|
|
137
|
+
<input value={state.search} onChange={e => vm.setSearch(e.target.value)} />
|
|
138
|
+
{loading && <Spinner />}
|
|
139
|
+
{error && <ErrorBanner message={error} />}
|
|
140
|
+
<LocationsTable locations={vm.filtered} />
|
|
141
|
+
<p>Showing {vm.filtered.length} of {vm.total}</p>
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
No `useEffect`. No `useState`. No `useMemo`. No `useCallback`. The ViewModel is the hook.
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## ViewModel Section Order
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
class MyViewModel extends ViewModel<State, Events> {
|
|
155
|
+
// --- Private fields ---
|
|
156
|
+
private service = singleton(MyService);
|
|
157
|
+
private collection = singleton(MyCollection);
|
|
158
|
+
|
|
159
|
+
// --- Computed getters ---
|
|
160
|
+
get filtered(): Item[] { /* ... */ }
|
|
161
|
+
get total(): number { /* ... */ }
|
|
162
|
+
|
|
163
|
+
// --- Lifecycle ---
|
|
164
|
+
protected onInit() { /* ... */ }
|
|
165
|
+
|
|
166
|
+
// --- Actions ---
|
|
167
|
+
async load() { /* ... */ }
|
|
168
|
+
async save() { /* ... */ }
|
|
169
|
+
|
|
170
|
+
// --- Setters ---
|
|
171
|
+
setSearch(search: string) { this.set({ search }); }
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## Imperative Events
|
|
178
|
+
|
|
179
|
+
For toasts, navigation, animations — one-shot signals that don't belong in state.
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
interface SaveEvents {
|
|
183
|
+
saved: { id: string };
|
|
184
|
+
validationFailed: void;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
class ItemViewModel extends ViewModel<State, SaveEvents> {
|
|
188
|
+
async save() {
|
|
189
|
+
const result = await this.service.save(this.state.draft, this.disposeSignal);
|
|
190
|
+
this.emit('saved', { id: result.id });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
```tsx
|
|
196
|
+
// Component subscribes directly on the ViewModel
|
|
197
|
+
useEvent(vm, 'saved', ({ id }) => toast.success(`Saved ${id}`));
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## Service Pattern
|
|
203
|
+
|
|
204
|
+
```typescript
|
|
205
|
+
class UserService extends Service {
|
|
206
|
+
async getAll(signal?: AbortSignal): Promise<User[]> {
|
|
207
|
+
const res = await fetch('/api/users', { signal });
|
|
208
|
+
if (!res.ok) throw new HttpError(res.status, res.statusText);
|
|
209
|
+
return res.json();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
- Stateless, singleton, accept `AbortSignal`, throw `HttpError`.
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
## Collection Pattern
|
|
219
|
+
|
|
220
|
+
```typescript
|
|
221
|
+
class UsersCollection extends Collection<UserState> {}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Thin subclass for singleton identity. No custom methods — query logic goes in ViewModel getters.
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## Optimistic Updates
|
|
229
|
+
|
|
230
|
+
```typescript
|
|
231
|
+
async toggleStatus(id: string) {
|
|
232
|
+
const rollback = this.collection.optimistic(() => {
|
|
233
|
+
this.collection.update(id, { status: 'done' });
|
|
234
|
+
});
|
|
235
|
+
try {
|
|
236
|
+
await this.service.update(id, { status: 'done' }, this.disposeSignal);
|
|
237
|
+
} catch (e) {
|
|
238
|
+
if (!isAbortError(e)) rollback();
|
|
239
|
+
throw e; // re-throw for async tracking
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
## Sharing Patterns
|
|
247
|
+
|
|
248
|
+
**Pattern A — Parent ViewModel with props** (default):
|
|
249
|
+
```tsx
|
|
250
|
+
function OrderPage() {
|
|
251
|
+
const [state, vm] = useLocal(OrderViewModel, { /* ... */ });
|
|
252
|
+
return (
|
|
253
|
+
<div>
|
|
254
|
+
<OrderItems items={vm.visibleItems} onRemove={id => vm.removeItem(id)} />
|
|
255
|
+
<OrderSummary total={vm.total} onSubmit={() => vm.submit()} />
|
|
256
|
+
</div>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
**Pattern B — Singleton ViewModel** (app-wide state):
|
|
262
|
+
```tsx
|
|
263
|
+
const [state, vm] = useSingleton(CartViewModel, { items: [] });
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
**Pattern C — Shared Collection** (different views of same data):
|
|
267
|
+
```tsx
|
|
268
|
+
// UsersTable uses UsersViewModel → subscribes to UsersCollection
|
|
269
|
+
// OnDutySidebar uses OnDutyViewModel → subscribes to UsersCollection
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
---
|
|
273
|
+
|
|
274
|
+
## useLocal with Dependencies
|
|
275
|
+
|
|
276
|
+
Recreates instance when deps change:
|
|
277
|
+
```tsx
|
|
278
|
+
const [state, vm] = useLocal(UserViewModel, { userId, data: null }, [userId]);
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
---
|
|
282
|
+
|
|
283
|
+
## Model Pattern
|
|
284
|
+
|
|
285
|
+
```typescript
|
|
286
|
+
class UserFormModel extends Model<UserFormState> {
|
|
287
|
+
setName(name: string) { this.set({ name }); }
|
|
288
|
+
setEmail(email: string) { this.set({ email }); }
|
|
289
|
+
|
|
290
|
+
protected validate(state: UserFormState) {
|
|
291
|
+
const errors: Partial<Record<keyof UserFormState, string>> = {};
|
|
292
|
+
if (!state.name.trim()) errors.name = 'Required';
|
|
293
|
+
if (!state.email.includes('@')) errors.email = 'Invalid email';
|
|
294
|
+
return errors;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
```tsx
|
|
300
|
+
const { state, errors, valid, dirty, model } = useModel(() => new UserFormModel({ name: '', email: '' }));
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
305
|
+
## Testing Pattern
|
|
306
|
+
|
|
307
|
+
```typescript
|
|
308
|
+
import { singleton, teardownAll } from 'mvc-kit';
|
|
309
|
+
|
|
310
|
+
beforeEach(() => teardownAll());
|
|
311
|
+
|
|
312
|
+
test('filtered getter applies search', () => {
|
|
313
|
+
const collection = singleton(UsersCollection);
|
|
314
|
+
collection.reset([
|
|
315
|
+
{ id: '1', firstName: 'Alice', status: 'on_duty' },
|
|
316
|
+
{ id: '2', firstName: 'Bob', status: 'off_duty' },
|
|
317
|
+
]);
|
|
318
|
+
|
|
319
|
+
const vm = new UsersViewModel({ items: [], search: '', roleFilter: 'all' });
|
|
320
|
+
vm.init();
|
|
321
|
+
|
|
322
|
+
expect(vm.filtered).toHaveLength(2);
|
|
323
|
+
vm.setSearch('alice');
|
|
324
|
+
expect(vm.filtered).toHaveLength(1);
|
|
325
|
+
|
|
326
|
+
vm.dispose();
|
|
327
|
+
});
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
---
|
|
331
|
+
|
|
332
|
+
## Error Handling Layers
|
|
333
|
+
|
|
334
|
+
1. **Async tracking** (automatic) — write happy path, read `vm.async.method.error`
|
|
335
|
+
2. **Imperative events** (explicit) — try/catch + `emit()` + **re-throw**
|
|
336
|
+
3. **Error classification** (services) — `throw HttpError`, `classifyError()`
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: review
|
|
3
|
+
description: "Review code for mvc-kit pattern adherence. Usage: /mvc-kit:review <path>"
|
|
4
|
+
invocable_by:
|
|
5
|
+
- user
|
|
6
|
+
- model
|
|
7
|
+
user_instructions: |
|
|
8
|
+
Usage: /mvc-kit:review <path>
|
|
9
|
+
|
|
10
|
+
Reviews files at the given path for mvc-kit pattern adherence.
|
|
11
|
+
Accepts a file path or directory.
|
|
12
|
+
|
|
13
|
+
Examples:
|
|
14
|
+
/mvc-kit:review src/viewmodels/
|
|
15
|
+
/mvc-kit:review src/viewmodels/OrderViewModel.ts
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# Review mvc-kit Code
|
|
19
|
+
|
|
20
|
+
Parse `$ARGUMENTS` as a file or directory path. If a directory, review all `.ts` and `.tsx` files within it.
|
|
21
|
+
|
|
22
|
+
## Instructions
|
|
23
|
+
|
|
24
|
+
1. Read the target file(s).
|
|
25
|
+
2. Identify mvc-kit imports (`mvc-kit`, `mvc-kit/react`) to determine file types.
|
|
26
|
+
3. Check each file against the categorized checklist in `checklist.md`.
|
|
27
|
+
4. Output findings grouped by severity.
|
|
28
|
+
|
|
29
|
+
## Output Format
|
|
30
|
+
|
|
31
|
+
For each finding:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
[SEVERITY] file:line — Violation
|
|
35
|
+
Problem: What's wrong
|
|
36
|
+
Fix: Concrete before/after code
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Severity levels:
|
|
40
|
+
- **CRITICAL** — Will cause bugs, memory leaks, infinite loops, or state desync. Must fix.
|
|
41
|
+
- **WARNING** — Anti-pattern that hurts maintainability. Should fix.
|
|
42
|
+
- **SUGGESTION** — Improvement for readability or DX. Nice to have.
|
|
43
|
+
|
|
44
|
+
## Summary
|
|
45
|
+
|
|
46
|
+
After individual findings, provide:
|
|
47
|
+
- Total findings by severity
|
|
48
|
+
- Overall assessment (healthy / needs attention / significant issues)
|
|
49
|
+
- Top 3 priorities to address
|
|
50
|
+
|
|
51
|
+
## Reference
|
|
52
|
+
|
|
53
|
+
See `checklist.md` for the complete categorized checklist by file type.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# mvc-kit Review Checklist
|
|
2
|
+
|
|
3
|
+
## ViewModel Checks (15)
|
|
4
|
+
|
|
5
|
+
### Critical
|
|
6
|
+
1. **No loading/error in State** — State interface must not contain `loading`, `error`, `isLoading`, or similar. Use `vm.async.method`.
|
|
7
|
+
2. **No derived state in State** — State must not contain values computable from other state (filtered lists, counts, flags). Use getters.
|
|
8
|
+
3. **No `set()` inside getters** — Creates infinite loop. Derived values must be pure computations.
|
|
9
|
+
4. **`disposeSignal` on async calls** — Every `fetch()`, service call, or async operation must receive `this.disposeSignal` or a composed signal.
|
|
10
|
+
5. **Re-throw in try/catch** — Any explicit try/catch must re-throw the error so async tracking captures it. Only guard with `isAbortError()`.
|
|
11
|
+
|
|
12
|
+
### Warning
|
|
13
|
+
6. **Section order** — Must follow: Private fields → Computed getters → Lifecycle → Actions → Setters.
|
|
14
|
+
7. **No two-step setters** — Setters should be one-liners (`this.set({ x })`). No manual refilter/rederive calls after `set()`.
|
|
15
|
+
8. **Smart init pattern** — When subscribing to a Collection, check `collection.length > 0` before fetching.
|
|
16
|
+
9. **Use `subscribeTo`** — Collection subscriptions must use `subscribeTo()` (auto-cleanup), not manual `subscribe()` + `addCleanup()`.
|
|
17
|
+
10. **Use `collection.optimistic()`** — No manual snapshot/restore for optimistic updates.
|
|
18
|
+
11. **Singleton resolution as property** — Dependencies resolved via `private service = singleton(MyService)`, not in `onInit()`.
|
|
19
|
+
|
|
20
|
+
### Suggestion
|
|
21
|
+
12. **Action naming** — Public methods should use user-intent verbs (`load`, `save`, `submit`), not implementation names (`fetchFromApi`).
|
|
22
|
+
13. **ViewModel events vs separate EventBus** — Use the second generic parameter for component-scoped events, not a separate EventBus instance.
|
|
23
|
+
14. **Giant ViewModel** — Flag >10 state properties or >8 public methods. Consider splitting.
|
|
24
|
+
15. **Getter composition** — Getters should compose from other getters where natural (e.g., `get isEmpty()` uses `this.filtered`).
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Component Checks (10)
|
|
29
|
+
|
|
30
|
+
### Critical
|
|
31
|
+
1. **One `useLocal` per component** — Multiple `useLocal` calls must be split into separate components.
|
|
32
|
+
2. **No `useEffect` for data loading** — Data loading belongs in `onInit()`, not `useEffect`.
|
|
33
|
+
3. **No direct Collection/Service imports** — Components import only their ViewModel and React hooks.
|
|
34
|
+
|
|
35
|
+
### Warning
|
|
36
|
+
4. **No `useState`/`useMemo`/`useCallback`** — The ViewModel replaces these hooks. UI state belongs in the ViewModel.
|
|
37
|
+
5. **No logic/filtering in components** — Derivation belongs in ViewModel getters. Components should only read `vm.x`.
|
|
38
|
+
6. **Correct state access** — Raw values via `state.x`, computed via `vm.x`, async via `vm.async.x`.
|
|
39
|
+
7. **Connected vs presentational** — Connected components own a ViewModel. Presentational components receive props with no mvc-kit imports.
|
|
40
|
+
|
|
41
|
+
### Suggestion
|
|
42
|
+
8. **Destructure async** — `const { loading, error } = vm.async.load` for readability.
|
|
43
|
+
9. **Presentational ratio** — Many presentational, few connected. Components doing too much should extract presentational children.
|
|
44
|
+
10. **`useLocal` deps** — When tied to route params or props that change, pass `deps` array to `useLocal`.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Service Checks (5)
|
|
49
|
+
|
|
50
|
+
### Critical
|
|
51
|
+
1. **Accept `AbortSignal`** — Every async method must accept an optional `signal` parameter.
|
|
52
|
+
2. **Throw `HttpError`** — Non-OK responses must throw `HttpError(status, statusText)` for error classification.
|
|
53
|
+
|
|
54
|
+
### Warning
|
|
55
|
+
3. **Stateless** — Services must not cache data. That's a Collection's job.
|
|
56
|
+
4. **No ViewModel/Collection knowledge** — Services sit at the bottom. No imports from viewmodels or collections.
|
|
57
|
+
|
|
58
|
+
### Suggestion
|
|
59
|
+
5. **Method naming** — `getAll`, `getById`, `create`, `update`, `delete` — standard REST verbs.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Collection Checks (5)
|
|
64
|
+
|
|
65
|
+
### Critical
|
|
66
|
+
1. **Thin subclass** — Collections should be `class MyCollection extends Collection<MyType> {}` with no custom methods.
|
|
67
|
+
2. **Never component-facing** — Components must not import or subscribe to Collections.
|
|
68
|
+
|
|
69
|
+
### Warning
|
|
70
|
+
3. **One per entity type** — Don't reuse a Collection for different entity types.
|
|
71
|
+
4. **Items have `id: string`** — Collection items must have an `id` field of type `string`.
|
|
72
|
+
|
|
73
|
+
### Suggestion
|
|
74
|
+
5. **Query in ViewModel** — Filtering/sorting logic belongs in ViewModel getters, not Collection methods.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Test Checks (5)
|
|
79
|
+
|
|
80
|
+
### Critical
|
|
81
|
+
1. **`teardownAll()` in beforeEach** — Singleton registry must be reset between tests.
|
|
82
|
+
2. **Dispose after test** — ViewModel/Model instances created in tests must be disposed.
|
|
83
|
+
|
|
84
|
+
### Warning
|
|
85
|
+
3. **Test state and getters** — Tests should assert `vm.state.x` and `vm.x` (getters), not internal fields.
|
|
86
|
+
4. **Test async tracking** — For async methods, assert `vm.async.method.loading` and `vm.async.method.error`.
|
|
87
|
+
|
|
88
|
+
### Suggestion
|
|
89
|
+
5. **Counter-based getter testing** — Use subclass override pattern for verifying getter memoization.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: scaffold
|
|
3
|
+
description: "Scaffold mvc-kit classes with correct patterns. Usage: /mvc-kit:scaffold <type> <Name>"
|
|
4
|
+
invocable_by:
|
|
5
|
+
- user
|
|
6
|
+
- model
|
|
7
|
+
user_instructions: |
|
|
8
|
+
Usage: /mvc-kit:scaffold <type> <Name>
|
|
9
|
+
|
|
10
|
+
Types: viewmodel, model, collection, service, eventbus, channel, controller, page-component
|
|
11
|
+
|
|
12
|
+
Examples:
|
|
13
|
+
/mvc-kit:scaffold viewmodel OrderList
|
|
14
|
+
/mvc-kit:scaffold service Payment
|
|
15
|
+
/mvc-kit:scaffold page-component Users
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# Scaffold mvc-kit Class
|
|
19
|
+
|
|
20
|
+
Parse `$ARGUMENTS` as `<type> <Name>` (case-insensitive type, PascalCase Name).
|
|
21
|
+
|
|
22
|
+
## Instructions
|
|
23
|
+
|
|
24
|
+
1. Parse the arguments. If missing, ask the user for `<type>` and `<Name>`.
|
|
25
|
+
2. Read the template file from `templates/<type>.md` in this skill directory.
|
|
26
|
+
3. Replace all `{{Name}}` placeholders with the provided Name.
|
|
27
|
+
4. Generate the implementation file(s) following the template exactly.
|
|
28
|
+
5. Also generate a colocated test file following mvc-kit test conventions.
|
|
29
|
+
|
|
30
|
+
## Valid Types
|
|
31
|
+
|
|
32
|
+
| Type | Template | Files Generated |
|
|
33
|
+
|------|----------|----------------|
|
|
34
|
+
| `viewmodel` | `templates/viewmodel.md` | `{{Name}}ViewModel.ts`, `{{Name}}ViewModel.test.ts` |
|
|
35
|
+
| `model` | `templates/model.md` | `{{Name}}Model.ts`, `{{Name}}Model.test.ts` |
|
|
36
|
+
| `collection` | `templates/collection.md` | `{{Name}}Collection.ts` |
|
|
37
|
+
| `service` | `templates/service.md` | `{{Name}}Service.ts`, `{{Name}}Service.test.ts` |
|
|
38
|
+
| `eventbus` | `templates/eventbus.md` | `{{Name}}EventBus.ts` |
|
|
39
|
+
| `channel` | `templates/channel.md` | `{{Name}}Channel.ts` |
|
|
40
|
+
| `controller` | `templates/controller.md` | `{{Name}}Controller.ts` |
|
|
41
|
+
| `page-component` | `templates/page-component.md` | `{{Name}}Page.tsx` |
|
|
42
|
+
|
|
43
|
+
## Rules
|
|
44
|
+
|
|
45
|
+
- Follow the ViewModel section order: Private fields → Computed getters → Lifecycle → Actions → Setters.
|
|
46
|
+
- State interfaces hold only source-of-truth values. No derived values, no loading/error flags.
|
|
47
|
+
- Services are stateless, accept `AbortSignal`, throw `HttpError`.
|
|
48
|
+
- Collections are thin subclasses — no custom methods.
|
|
49
|
+
- Use `singleton()` for dependency resolution in ViewModels.
|
|
50
|
+
- Use `subscribeTo()` for Collection subscriptions with smart init pattern.
|
|
51
|
+
- Pass `this.disposeSignal` to every async call.
|
|
52
|
+
- Test files use `teardownAll()` in `beforeEach`.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Channel Template: {{Name}}
|
|
2
|
+
|
|
3
|
+
## {{Name}}Channel.ts
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
import { Channel } from 'mvc-kit';
|
|
7
|
+
|
|
8
|
+
export interface {{Name}}Messages {
|
|
9
|
+
// TODO: Define your message types
|
|
10
|
+
// message: { id: string; text: string; timestamp: number };
|
|
11
|
+
// status: { userId: string; online: boolean };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class {{Name}}Channel extends Channel<{{Name}}Messages> {
|
|
15
|
+
private ws: WebSocket | null = null;
|
|
16
|
+
|
|
17
|
+
protected connect(): void {
|
|
18
|
+
// TODO: Set your WebSocket URL
|
|
19
|
+
this.ws = new WebSocket('wss://example.com/ws');
|
|
20
|
+
|
|
21
|
+
this.ws.onopen = () => {
|
|
22
|
+
this.setConnected();
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
this.ws.onclose = () => {
|
|
26
|
+
this.setDisconnected();
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
this.ws.onerror = () => {
|
|
30
|
+
this.setDisconnected();
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
this.ws.onmessage = (event) => {
|
|
34
|
+
const data = JSON.parse(event.data);
|
|
35
|
+
// TODO: Route messages by type
|
|
36
|
+
// this.receive('message', data);
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
protected disconnect(): void {
|
|
41
|
+
this.ws?.close();
|
|
42
|
+
this.ws = null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Optional: send messages
|
|
46
|
+
send(data: unknown): void {
|
|
47
|
+
this.ws?.send(JSON.stringify(data));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Usage in a ViewModel
|
|
53
|
+
|
|
54
|
+
```typescript
|
|
55
|
+
import { ViewModel, singleton } from 'mvc-kit';
|
|
56
|
+
import { {{Name}}Channel } from '../channels/{{Name}}Channel';
|
|
57
|
+
|
|
58
|
+
interface State {
|
|
59
|
+
connectionStatus: string;
|
|
60
|
+
messages: any[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
class {{Name}}ViewModel extends ViewModel<State> {
|
|
64
|
+
private channel = singleton({{Name}}Channel);
|
|
65
|
+
|
|
66
|
+
protected onInit() {
|
|
67
|
+
// Mirror connection status
|
|
68
|
+
this.subscribeTo(this.channel, () => {
|
|
69
|
+
this.set({ connectionStatus: this.channel.state });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Listen to messages (manual cleanup needed for channel.on)
|
|
73
|
+
const unsub = this.channel.on('message', (msg) => {
|
|
74
|
+
this.set({ messages: [...this.state.messages, msg] });
|
|
75
|
+
});
|
|
76
|
+
this.addCleanup(unsub);
|
|
77
|
+
|
|
78
|
+
this.channel.open();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Notes
|
|
84
|
+
|
|
85
|
+
- Channels are **singletons** — shared across ViewModels
|
|
86
|
+
- `subscribeTo(channel)` tracks connection status changes (auto-cleanup)
|
|
87
|
+
- `channel.on(event)` subscriptions need manual `addCleanup` registration
|
|
88
|
+
- Auto-reconnect is built-in: configure via static `RECONNECT_DELAY`, `MAX_RECONNECT_DELAY`, `MAX_RECONNECT_ATTEMPTS`
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Collection Template: {{Name}}
|
|
2
|
+
|
|
3
|
+
## {{Name}}Collection.ts
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
import { Collection } from 'mvc-kit';
|
|
7
|
+
|
|
8
|
+
export interface {{Name}}Item {
|
|
9
|
+
id: string;
|
|
10
|
+
// TODO: Add your fields
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class {{Name}}Collection extends Collection<{{Name}}Item> {}
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Collections are thin subclasses — singleton identity only. Query logic belongs in ViewModel getters.
|
|
17
|
+
|
|
18
|
+
## Usage in a ViewModel
|
|
19
|
+
|
|
20
|
+
```typescript
|
|
21
|
+
import { ViewModel, singleton } from 'mvc-kit';
|
|
22
|
+
import { {{Name}}Collection } from '../collections/{{Name}}Collection';
|
|
23
|
+
import type { {{Name}}Item } from '../collections/{{Name}}Collection';
|
|
24
|
+
|
|
25
|
+
interface State {
|
|
26
|
+
items: {{Name}}Item[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class {{Name}}ViewModel extends ViewModel<State> {
|
|
30
|
+
private collection = singleton({{Name}}Collection);
|
|
31
|
+
|
|
32
|
+
protected onInit() {
|
|
33
|
+
this.subscribeTo(this.collection, () => {
|
|
34
|
+
this.set({ items: this.collection.items });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
if (this.collection.length > 0) {
|
|
38
|
+
this.set({ items: this.collection.items });
|
|
39
|
+
} else {
|
|
40
|
+
this.load();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async load() {
|
|
45
|
+
// const data = await this.service.getAll(this.disposeSignal);
|
|
46
|
+
// this.collection.reset(data);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
```
|