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,321 @@
|
|
|
1
|
+
# mvc-kit Anti-Patterns
|
|
2
|
+
|
|
3
|
+
Reject these patterns. Each entry shows the bad pattern and the correct alternative.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. Loading/Error Flags in State
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
// BAD
|
|
11
|
+
interface State {
|
|
12
|
+
items: Item[];
|
|
13
|
+
loading: boolean;
|
|
14
|
+
error: string | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// GOOD — async tracking handles it
|
|
18
|
+
interface State {
|
|
19
|
+
items: Item[];
|
|
20
|
+
}
|
|
21
|
+
// Read: vm.async.load.loading, vm.async.load.error
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## 2. Stored Derived State
|
|
27
|
+
|
|
28
|
+
```typescript
|
|
29
|
+
// BAD — manually syncing derived state
|
|
30
|
+
interface State {
|
|
31
|
+
items: Item[];
|
|
32
|
+
search: string;
|
|
33
|
+
filtered: Item[]; // derived value stored in state
|
|
34
|
+
}
|
|
35
|
+
setSearch(search: string) {
|
|
36
|
+
this.set({ search });
|
|
37
|
+
this.refilter(); // manual sync step
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// GOOD — getter computes automatically
|
|
41
|
+
get filtered(): Item[] {
|
|
42
|
+
return this.state.items.filter(i => i.name.includes(this.state.search));
|
|
43
|
+
}
|
|
44
|
+
setSearch(search: string) { this.set({ search }); }
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## 3. Manual Try/Catch for Standard Loads
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
// BAD — unnecessary boilerplate
|
|
53
|
+
async load() {
|
|
54
|
+
this.set({ loading: true, error: null });
|
|
55
|
+
try {
|
|
56
|
+
const data = await this.service.getAll(this.disposeSignal);
|
|
57
|
+
this.set({ items: data, loading: false });
|
|
58
|
+
} catch (e) {
|
|
59
|
+
if (isAbortError(e)) return;
|
|
60
|
+
this.set({ loading: false, error: e.message });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// GOOD — async tracking handles everything
|
|
65
|
+
async load() {
|
|
66
|
+
const data = await this.service.getAll(this.disposeSignal);
|
|
67
|
+
this.collection.reset(data);
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## 4. Two-Step Setters
|
|
74
|
+
|
|
75
|
+
```typescript
|
|
76
|
+
// BAD — setter calls refilter
|
|
77
|
+
setSearch(search: string) {
|
|
78
|
+
this.set({ search });
|
|
79
|
+
this.refilter(); // unnecessary — getters recompute on access
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// GOOD — one-liner setter, getter handles derivation
|
|
83
|
+
setSearch(search: string) { this.set({ search }); }
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## 5. Multiple useLocal in One Component
|
|
89
|
+
|
|
90
|
+
```tsx
|
|
91
|
+
// BAD — split into two components
|
|
92
|
+
function Dashboard() {
|
|
93
|
+
const [usersState, usersVM] = useLocal(UsersViewModel, { ... });
|
|
94
|
+
const [ordersState, ordersVM] = useLocal(OrdersViewModel, { ... });
|
|
95
|
+
return <div>...</div>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// GOOD — each component owns one ViewModel
|
|
99
|
+
function Dashboard() {
|
|
100
|
+
return (
|
|
101
|
+
<div>
|
|
102
|
+
<UsersPanel />
|
|
103
|
+
<OrdersPanel />
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## 6. Collections Accessed from Components
|
|
112
|
+
|
|
113
|
+
```tsx
|
|
114
|
+
// BAD — component imports infrastructure
|
|
115
|
+
import { singleton } from 'mvc-kit';
|
|
116
|
+
import { UsersCollection } from '../collections/UsersCollection';
|
|
117
|
+
function UsersPage() {
|
|
118
|
+
const collection = singleton(UsersCollection);
|
|
119
|
+
// ...
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// GOOD — component only knows its ViewModel
|
|
123
|
+
import { useLocal } from 'mvc-kit/react';
|
|
124
|
+
import { UsersViewModel } from '../viewmodels/UsersViewModel';
|
|
125
|
+
function UsersPage() {
|
|
126
|
+
const [state, vm] = useLocal(UsersViewModel, { ... });
|
|
127
|
+
// ...
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## 7. Services Holding State
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
// BAD — service caches data
|
|
137
|
+
class UserService extends Service {
|
|
138
|
+
private cache: User[] = [];
|
|
139
|
+
async getAll() {
|
|
140
|
+
if (this.cache.length) return this.cache;
|
|
141
|
+
this.cache = await fetch(...).then(r => r.json());
|
|
142
|
+
return this.cache;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// GOOD — Collection is the cache, Service is stateless
|
|
147
|
+
class UserService extends Service {
|
|
148
|
+
async getAll(signal?: AbortSignal): Promise<User[]> {
|
|
149
|
+
const res = await fetch('/api/users', { signal });
|
|
150
|
+
if (!res.ok) throw new HttpError(res.status, res.statusText);
|
|
151
|
+
return res.json();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## 8. Swallowing Errors Without Re-Throwing
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
// BAD — breaks async tracking
|
|
162
|
+
async save() {
|
|
163
|
+
try {
|
|
164
|
+
await this.service.save(this.state.draft, this.disposeSignal);
|
|
165
|
+
this.emit('saved', { id: this.state.draft.id });
|
|
166
|
+
} catch (e) {
|
|
167
|
+
this.emit('error', { message: e.message });
|
|
168
|
+
// Error swallowed! vm.async.save.error will be null
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// GOOD — re-throw for async tracking
|
|
173
|
+
async save() {
|
|
174
|
+
try {
|
|
175
|
+
await this.service.save(this.state.draft, this.disposeSignal);
|
|
176
|
+
this.emit('saved', { id: this.state.draft.id });
|
|
177
|
+
} catch (e) {
|
|
178
|
+
if (!isAbortError(e)) {
|
|
179
|
+
this.emit('error', { message: classifyError(e).message });
|
|
180
|
+
}
|
|
181
|
+
throw e; // async tracking captures the error
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## 9. useEffect for Data Loading
|
|
189
|
+
|
|
190
|
+
```tsx
|
|
191
|
+
// BAD — component orchestrates loading
|
|
192
|
+
function UsersPage() {
|
|
193
|
+
const [state, vm] = useLocal(UsersViewModel, { ... });
|
|
194
|
+
useEffect(() => { vm.load(); }, []);
|
|
195
|
+
return <div>...</div>;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// GOOD — ViewModel handles its own initialization
|
|
199
|
+
// (onInit() is called automatically by useLocal)
|
|
200
|
+
function UsersPage() {
|
|
201
|
+
const [state, vm] = useLocal(UsersViewModel, { ... });
|
|
202
|
+
return <div>...</div>;
|
|
203
|
+
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## 10. useState/useMemo/useCallback in Connected Components
|
|
209
|
+
|
|
210
|
+
```tsx
|
|
211
|
+
// BAD — React hooks alongside ViewModel
|
|
212
|
+
function UsersPage() {
|
|
213
|
+
const [state, vm] = useLocal(UsersViewModel, { ... });
|
|
214
|
+
const [showModal, setShowModal] = useState(false);
|
|
215
|
+
const filtered = useMemo(() => state.items.filter(...), [state.items]);
|
|
216
|
+
|
|
217
|
+
// GOOD — put it all in the ViewModel
|
|
218
|
+
// state.showModal, vm.filtered, vm.toggleModal()
|
|
219
|
+
}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## 11. Manual Optimistic Snapshot/Restore
|
|
225
|
+
|
|
226
|
+
```typescript
|
|
227
|
+
// BAD — manual snapshot management
|
|
228
|
+
async toggleStatus(id: string) {
|
|
229
|
+
const prev = [...this.collection.items];
|
|
230
|
+
this.collection.update(id, { status: 'done' });
|
|
231
|
+
try {
|
|
232
|
+
await this.service.update(id, { status: 'done' }, this.disposeSignal);
|
|
233
|
+
} catch {
|
|
234
|
+
this.collection.reset(prev); // manual restore
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// GOOD — use collection.optimistic()
|
|
239
|
+
async toggleStatus(id: string) {
|
|
240
|
+
const rollback = this.collection.optimistic(() => {
|
|
241
|
+
this.collection.update(id, { status: 'done' });
|
|
242
|
+
});
|
|
243
|
+
try {
|
|
244
|
+
await this.service.update(id, { status: 'done' }, this.disposeSignal);
|
|
245
|
+
} catch (e) {
|
|
246
|
+
if (!isAbortError(e)) rollback();
|
|
247
|
+
throw e;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## 12. Separate EventBus for ViewModel Events
|
|
255
|
+
|
|
256
|
+
```typescript
|
|
257
|
+
// BAD — unnecessary EventBus when ViewModel events suffice
|
|
258
|
+
class ItemViewModel extends ViewModel<State> {
|
|
259
|
+
private eventBus = new EventBus<{ saved: { id: string } }>();
|
|
260
|
+
get bus() { return this.eventBus; }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// GOOD — use the second generic parameter
|
|
264
|
+
class ItemViewModel extends ViewModel<State, { saved: { id: string } }> {
|
|
265
|
+
// emit('saved', { id }) is built-in and protected
|
|
266
|
+
}
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
---
|
|
270
|
+
|
|
271
|
+
## 13. set() Inside a Getter
|
|
272
|
+
|
|
273
|
+
```typescript
|
|
274
|
+
// BAD — creates infinite loop
|
|
275
|
+
get filtered(): Item[] {
|
|
276
|
+
const result = this.state.items.filter(...);
|
|
277
|
+
this.set({ filteredCount: result.length }); // INFINITE LOOP
|
|
278
|
+
return result;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// GOOD — use a separate getter for the count
|
|
282
|
+
get filtered(): Item[] { return this.state.items.filter(...); }
|
|
283
|
+
get filteredCount(): number { return this.filtered.length; }
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
---
|
|
287
|
+
|
|
288
|
+
## 14. Logic/Filtering in Components
|
|
289
|
+
|
|
290
|
+
```tsx
|
|
291
|
+
// BAD — component does derivation
|
|
292
|
+
function UsersPage() {
|
|
293
|
+
const [state, vm] = useLocal(UsersViewModel, { ... });
|
|
294
|
+
const active = state.items.filter(u => u.status === 'active');
|
|
295
|
+
return <UserList users={active} />;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// GOOD — getter on the ViewModel
|
|
299
|
+
function UsersPage() {
|
|
300
|
+
const [state, vm] = useLocal(UsersViewModel, { ... });
|
|
301
|
+
return <UserList users={vm.active} />;
|
|
302
|
+
}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
---
|
|
306
|
+
|
|
307
|
+
## 15. Missing disposeSignal on Async Calls
|
|
308
|
+
|
|
309
|
+
```typescript
|
|
310
|
+
// BAD — no cancellation on unmount
|
|
311
|
+
async load() {
|
|
312
|
+
const data = await this.service.getAll();
|
|
313
|
+
this.set({ items: data });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// GOOD — cancels on unmount, AbortError auto-swallowed
|
|
317
|
+
async load() {
|
|
318
|
+
const data = await this.service.getAll(this.disposeSignal);
|
|
319
|
+
this.set({ items: data });
|
|
320
|
+
}
|
|
321
|
+
```
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# mvc-kit API Reference
|
|
2
|
+
|
|
3
|
+
## Imports
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
// Core classes and utilities
|
|
7
|
+
import { ViewModel, Model, Collection, Controller, Service, EventBus, Channel } from 'mvc-kit';
|
|
8
|
+
import { singleton, hasSingleton, teardown, teardownAll } from 'mvc-kit';
|
|
9
|
+
import { HttpError, isAbortError, classifyError } from 'mvc-kit';
|
|
10
|
+
import type { Subscribable, Disposable, Initializable, Listener, Updater, ValidationErrors, TaskState, AppError, AsyncMethodKeys, ChannelStatus } from 'mvc-kit';
|
|
11
|
+
|
|
12
|
+
// React hooks
|
|
13
|
+
import { useLocal, useSingleton, useInstance, useModel, useField, useEvent, useEmit, useResolve, useTeardown, Provider } from 'mvc-kit/react';
|
|
14
|
+
import type { StateOf, ItemOf, SingletonClass, ProviderRegistry, ModelHandle, FieldHandle, ProviderProps } from 'mvc-kit/react';
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## ViewModel<S, E = {}>
|
|
20
|
+
|
|
21
|
+
Reactive state container with computed getters, automatic async tracking, and optional typed events.
|
|
22
|
+
|
|
23
|
+
### Constructor
|
|
24
|
+
```typescript
|
|
25
|
+
new MyViewModel(initialState: S)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### State
|
|
29
|
+
- `state: Readonly<S>` — Current frozen state. Read `state.x` for raw values.
|
|
30
|
+
- `set(partial: Partial<S>)` — Merge partial state. Skips if no values change.
|
|
31
|
+
- `set(updater: (prev: Readonly<S>) => Partial<S>)` — Functional update.
|
|
32
|
+
- `reset(newState?: S)` — Tear down lifecycle, re-initialize. Clears async tracking, re-runs `onInit()`.
|
|
33
|
+
|
|
34
|
+
### Computed Getters
|
|
35
|
+
Define TypeScript `get` accessors on the subclass. Auto-memoized after `init()` with dependency tracking.
|
|
36
|
+
```typescript
|
|
37
|
+
get filtered(): Item[] { return this.state.items.filter(i => i.active); }
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Async Tracking
|
|
41
|
+
After `init()`, every subclass method is wrapped. Async methods get automatic loading/error tracking.
|
|
42
|
+
- `async: Record<string, TaskState>` — `vm.async.methodName` returns `{ loading: boolean, error: string | null, errorCode: string | null }`.
|
|
43
|
+
- `subscribeAsync(listener: () => void): () => void` — Subscribe to async state changes.
|
|
44
|
+
- Unknown keys return `{ loading: false, error: null, errorCode: null }`.
|
|
45
|
+
|
|
46
|
+
### Typed Events (optional)
|
|
47
|
+
Pass a second generic `E` to enable typed events.
|
|
48
|
+
```typescript
|
|
49
|
+
class MyVM extends ViewModel<State, { saved: { id: string }; error: void }> {
|
|
50
|
+
protected emit(event, payload) // type-safe, protected
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
- `events: EventBus<E>` — Lazy getter; zero cost if unused.
|
|
54
|
+
- `emit(event, payload)` — Protected. Only the ViewModel can emit.
|
|
55
|
+
- Event bus auto-disposes with the ViewModel.
|
|
56
|
+
|
|
57
|
+
### Lifecycle Hooks
|
|
58
|
+
- `onInit(): void | Promise<void>` — Called once after `init()`. Data loading, subscriptions.
|
|
59
|
+
- `onSet(prev: Readonly<S>, next: Readonly<S>): void` — Called after every state change.
|
|
60
|
+
- `onDispose(): void` — Called during `dispose()`, after cleanup callbacks.
|
|
61
|
+
|
|
62
|
+
### Cleanup
|
|
63
|
+
- `disposeSignal: AbortSignal` — Lazily created, auto-aborted on `dispose()`.
|
|
64
|
+
- `subscribeTo(source: Subscribable, listener): void` — Auto-unsubscribe on dispose.
|
|
65
|
+
- `addCleanup(fn: () => void): void` — Register teardown callback.
|
|
66
|
+
- `subscribe(listener: Listener<S>): () => void` — Subscribe to state changes.
|
|
67
|
+
- `dispose(): void` — Idempotent. `set()` and `emit()` become no-ops after dispose.
|
|
68
|
+
|
|
69
|
+
### Static
|
|
70
|
+
- `static GHOST_TIMEOUT = 3000` — DEV-only. Timeout before ghost async warnings.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Model<S>
|
|
75
|
+
|
|
76
|
+
Entity with validation and dirty tracking.
|
|
77
|
+
|
|
78
|
+
### Constructor
|
|
79
|
+
```typescript
|
|
80
|
+
new MyModel(initialState: S)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### State & Validation
|
|
84
|
+
- `state: Readonly<S>` — Current state.
|
|
85
|
+
- `set(partial: Partial<S>)` — Update state, re-validates.
|
|
86
|
+
- `errors: ValidationErrors<S>` — `Partial<Record<keyof S, string>>`.
|
|
87
|
+
- `valid: boolean` — True when `errors` is empty.
|
|
88
|
+
- `dirty: boolean` — True when state differs from committed state.
|
|
89
|
+
- `commit(): void` — Mark current state as baseline (resets dirty).
|
|
90
|
+
- `rollback(): void` — Revert to committed state.
|
|
91
|
+
|
|
92
|
+
### Override
|
|
93
|
+
```typescript
|
|
94
|
+
protected validate(state: S): ValidationErrors<S> {
|
|
95
|
+
const errors: Partial<Record<keyof S, string>> = {};
|
|
96
|
+
if (!state.name) errors.name = 'Required';
|
|
97
|
+
return errors;
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Lifecycle & Cleanup
|
|
102
|
+
Same as ViewModel: `onInit()`, `onSet()`, `onDispose()`, `disposeSignal`, `subscribeTo()`, `addCleanup()`, `subscribe()`, `dispose()`.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Collection<T extends { id: string }>
|
|
107
|
+
|
|
108
|
+
Reactive typed array with CRUD and query methods. Items must have an `id: string` field.
|
|
109
|
+
|
|
110
|
+
### CRUD (triggers notifications)
|
|
111
|
+
- `add(item: T): void`
|
|
112
|
+
- `update(id: string, partial: Partial<T>): void`
|
|
113
|
+
- `remove(id: string): void`
|
|
114
|
+
- `reset(items: T[]): void` — Replace all items.
|
|
115
|
+
- `clear(): void`
|
|
116
|
+
|
|
117
|
+
### Query (pure, no notifications)
|
|
118
|
+
- `items: readonly T[]` — Same as `state`.
|
|
119
|
+
- `length: number`
|
|
120
|
+
- `get(id: string): T | undefined` — O(1) via internal index.
|
|
121
|
+
- `has(id: string): boolean`
|
|
122
|
+
- `find(predicate: (item: T) => boolean): T | undefined`
|
|
123
|
+
- `filter(predicate: (item: T) => boolean): T[]`
|
|
124
|
+
- `sorted(comparator: (a: T, b: T) => number): T[]`
|
|
125
|
+
- `map<U>(fn: (item: T) => U): U[]`
|
|
126
|
+
|
|
127
|
+
### Optimistic Updates
|
|
128
|
+
```typescript
|
|
129
|
+
const rollback = collection.optimistic(() => {
|
|
130
|
+
collection.update(id, { status: 'done' });
|
|
131
|
+
});
|
|
132
|
+
// On failure: rollback()
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Lifecycle & Cleanup
|
|
136
|
+
`subscribe()`, `dispose()`, `disposeSignal`, `addCleanup()`, `onDispose()`.
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Service
|
|
141
|
+
|
|
142
|
+
Stateless infrastructure adapter. Singleton-scoped.
|
|
143
|
+
|
|
144
|
+
### Lifecycle & Cleanup
|
|
145
|
+
- `disposeSignal: AbortSignal`
|
|
146
|
+
- `addCleanup(fn: () => void): void`
|
|
147
|
+
- `onDispose(): void`
|
|
148
|
+
- `dispose(): void`
|
|
149
|
+
- `disposed: boolean`
|
|
150
|
+
|
|
151
|
+
No state, no getters, no async tracking. Just a base class with lifecycle.
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## EventBus<E>
|
|
156
|
+
|
|
157
|
+
Typed pub/sub event bus.
|
|
158
|
+
|
|
159
|
+
- `on(event: K, handler: (payload: E[K]) => void): () => void` — Subscribe. Returns unsubscribe.
|
|
160
|
+
- `once(event: K, handler): () => void` — One-time subscription.
|
|
161
|
+
- `emit(event: K, payload: E[K]): void` — Emit event.
|
|
162
|
+
- `dispose(): void` — Remove all listeners.
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## Channel<M>
|
|
167
|
+
|
|
168
|
+
Persistent connection (WebSocket/SSE) with auto-reconnect. Extends EventBus for message routing.
|
|
169
|
+
|
|
170
|
+
### Subclass Contract
|
|
171
|
+
```typescript
|
|
172
|
+
protected abstract connect(): void; // Open the connection
|
|
173
|
+
protected abstract disconnect(): void; // Close the connection
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Connection Control
|
|
177
|
+
- `open(): void` — Start connection.
|
|
178
|
+
- `close(): void` — Stop connection and auto-reconnect.
|
|
179
|
+
- `state: ChannelStatus` — `'disconnected' | 'connecting' | 'connected' | 'reconnecting'`
|
|
180
|
+
- `subscribe(listener): () => void` — Subscribe to status changes.
|
|
181
|
+
|
|
182
|
+
### Auto-Reconnect
|
|
183
|
+
- `static RECONNECT_DELAY = 1000`
|
|
184
|
+
- `static MAX_RECONNECT_DELAY = 30000`
|
|
185
|
+
- `static MAX_RECONNECT_ATTEMPTS = Infinity`
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Controller
|
|
190
|
+
|
|
191
|
+
Stateless orchestrator. Component-scoped.
|
|
192
|
+
|
|
193
|
+
- `onInit(): void | Promise<void>`
|
|
194
|
+
- `onDispose(): void`
|
|
195
|
+
- `disposeSignal: AbortSignal`
|
|
196
|
+
- `subscribeTo(source, listener): void`
|
|
197
|
+
- `addCleanup(fn): void`
|
|
198
|
+
- `dispose(): void`
|
|
199
|
+
|
|
200
|
+
No state, no getters, no async tracking.
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## Singleton Registry
|
|
205
|
+
|
|
206
|
+
```typescript
|
|
207
|
+
singleton(Class, ...args) // Get or create singleton
|
|
208
|
+
hasSingleton(Class) // Check existence
|
|
209
|
+
teardown(Class) // Dispose and remove
|
|
210
|
+
teardownAll() // Dispose all (use in test beforeEach)
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## Error Utilities
|
|
216
|
+
|
|
217
|
+
- `HttpError(status: number, statusText: string)` — Throw from services.
|
|
218
|
+
- `isAbortError(error: unknown): boolean` — Guard for AbortError.
|
|
219
|
+
- `classifyError(error: unknown): AppError` — Returns `{ code, message, status? }`.
|
|
220
|
+
- Codes: `'unauthorized'`, `'forbidden'`, `'not_found'`, `'network'`, `'timeout'`, `'abort'`, `'server_error'`, `'unknown'`
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## React Hooks
|
|
225
|
+
|
|
226
|
+
### useLocal(Class, ...args) / useLocal(factory)
|
|
227
|
+
Component-scoped. Auto-init/dispose. Returns `[state, instance]` for Subscribables, `instance` for Disposable-only.
|
|
228
|
+
|
|
229
|
+
Optional `deps` array as final argument — recreates instance when deps change.
|
|
230
|
+
```tsx
|
|
231
|
+
const [state, vm] = useLocal(MyViewModel, { items: [] });
|
|
232
|
+
const [state, vm] = useLocal(MyViewModel, { userId, data: null }, [userId]);
|
|
233
|
+
const controller = useLocal(() => new MyController(dep1, dep2));
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### useSingleton(Class, ...args)
|
|
237
|
+
Singleton-scoped. Auto-init. Returns same as `useLocal`.
|
|
238
|
+
```tsx
|
|
239
|
+
const [state, vm] = useSingleton(CartViewModel, { items: [] });
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### useInstance(subscribable)
|
|
243
|
+
Subscribe to existing instance. No lifecycle management.
|
|
244
|
+
```tsx
|
|
245
|
+
const state = useInstance(existingVM);
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### useModel(factory)
|
|
249
|
+
Returns `ModelHandle: { state, errors, valid, dirty, model }`.
|
|
250
|
+
```tsx
|
|
251
|
+
const { state, errors, valid, dirty, model } = useModel(() => new UserModel({ name: '' }));
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### useField(model, key)
|
|
255
|
+
Returns `FieldHandle: { value, error, set }`. Surgical re-renders.
|
|
256
|
+
```tsx
|
|
257
|
+
const { value, error, set } = useField(model, 'name');
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### useEvent(source, event, handler)
|
|
261
|
+
Subscribe to EventBus or ViewModel event. Auto-unsubscribes.
|
|
262
|
+
```tsx
|
|
263
|
+
useEvent(vm, 'saved', ({ id }) => toast.success(`Saved ${id}`));
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
### useEmit(bus)
|
|
267
|
+
Stable emit function.
|
|
268
|
+
```tsx
|
|
269
|
+
const emit = useEmit(bus);
|
|
270
|
+
emit('user:login', { userId: '123' });
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### Provider & useResolve
|
|
274
|
+
DI for testing/Storybook.
|
|
275
|
+
```tsx
|
|
276
|
+
<Provider provide={[[ApiService, mockApi]]}>
|
|
277
|
+
<MyComponent />
|
|
278
|
+
</Provider>
|
|
279
|
+
|
|
280
|
+
const api = useResolve(ApiService); // falls back to singleton()
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### useTeardown(...Classes)
|
|
284
|
+
Teardown singletons on unmount.
|
|
285
|
+
```tsx
|
|
286
|
+
useTeardown(UserViewModel, CartViewModel);
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
## Interfaces
|
|
292
|
+
|
|
293
|
+
```typescript
|
|
294
|
+
interface Subscribable<S> { state: Readonly<S>; subscribe(listener: Listener<S>): () => void; dispose(): void; disposeSignal: AbortSignal; }
|
|
295
|
+
interface Disposable { disposed: boolean; disposeSignal: AbortSignal; dispose(): void; }
|
|
296
|
+
interface Initializable { initialized: boolean; init(): void; }
|
|
297
|
+
type Listener<S> = (state: Readonly<S>, prev: Readonly<S>) => void;
|
|
298
|
+
type Updater<S> = (state: Readonly<S>) => Partial<S>;
|
|
299
|
+
type ValidationErrors<S> = Partial<Record<keyof S, string>>;
|
|
300
|
+
interface TaskState { readonly loading: boolean; readonly error: string | null; readonly errorCode: string | null; }
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
## Dev Mode (`__MVC_KIT_DEV__`)
|
|
304
|
+
|
|
305
|
+
Enable in Vite:
|
|
306
|
+
```typescript
|
|
307
|
+
define: { __MVC_KIT_DEV__: process.env.NODE_ENV !== 'production' }
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Checks: `set()` inside getter, ghost async ops, method call after dispose, method call before init, reserved key override.
|