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.
@@ -0,0 +1,242 @@
1
+ # mvc-kit Framework Instructions
2
+
3
+ This project uses **mvc-kit**, a zero-dependency TypeScript-first reactive state management library for React. Follow these rules strictly when writing or reviewing code.
4
+
5
+ ## Core Classes
6
+
7
+ | Class | Role | Scope |
8
+ |-------|------|-------|
9
+ | `ViewModel<S, E?>` | Reactive state + computed getters + async tracking + typed events | Component-scoped (`useLocal`) |
10
+ | `Model<S>` | Entity with validation + dirty tracking + commit/rollback | Component-scoped (`useModel`) |
11
+ | `Collection<T>` | Reactive typed array, shared data cache, optimistic updates | Singleton |
12
+ | `Service` | Stateless infrastructure adapter (HTTP, storage) | Singleton |
13
+ | `EventBus<E>` | Typed pub/sub for cross-cutting events | Singleton |
14
+ | `Channel<M>` | Persistent connection (WebSocket/SSE) with auto-reconnect | Singleton |
15
+ | `Controller` | Stateless multi-ViewModel orchestrator (rare) | Component-scoped |
16
+
17
+ ## Imports
18
+
19
+ ```typescript
20
+ import { ViewModel, Model, Collection, Controller, Service, EventBus, Channel } from 'mvc-kit';
21
+ import { singleton, teardownAll, HttpError, isAbortError, classifyError } from 'mvc-kit';
22
+ import { useLocal, useSingleton, useInstance, useModel, useField, useEvent, useEmit, useResolve, useTeardown, Provider } from 'mvc-kit/react';
23
+ ```
24
+
25
+ ## Architecture Rules
26
+
27
+ 1. **State = source of truth only.** No derived values, no loading/error flags in state interfaces.
28
+ 2. **Derived values are `get` accessors** on the ViewModel. Auto-memoized after `init()`.
29
+ 3. **Async status is automatic.** After `init()`, `vm.async.methodName` returns `{ loading, error, errorCode }`. Never store loading/error in state.
30
+ 4. **One ViewModel per component** via `useLocal`. No `useEffect` for data loading — use `onInit()`.
31
+ 5. **No `useState`/`useMemo`/`useCallback`** — the ViewModel is the hook.
32
+ 6. **Components are declarative.** Read `state.x` for raw, `vm.x` for computed, `vm.async.x` for loading/error.
33
+ 7. **Collections are encapsulated.** ViewModels subscribe via `subscribeTo()` in `onInit()`. Components never import Collections.
34
+ 8. **Services are stateless.** Accept `AbortSignal`, throw `HttpError`, no knowledge of ViewModels.
35
+ 9. **Pass `this.disposeSignal`** to every async call.
36
+ 10. **Lifecycle**: `construct → init() → use → dispose()`. Hooks auto-call `init()`.
37
+
38
+ ## ViewModel Pattern
39
+
40
+ ```typescript
41
+ interface ItemState {
42
+ items: Item[];
43
+ search: string;
44
+ }
45
+
46
+ class ItemsViewModel extends ViewModel<ItemState> {
47
+ // --- Private fields ---
48
+ private service = singleton(ItemService);
49
+ private collection = singleton(ItemsCollection);
50
+
51
+ // --- Computed getters ---
52
+ get filtered(): Item[] {
53
+ const { items, search } = this.state;
54
+ if (!search) return items;
55
+ const q = search.toLowerCase();
56
+ return items.filter(i => i.name.toLowerCase().includes(q));
57
+ }
58
+
59
+ get total(): number { return this.state.items.length; }
60
+
61
+ // --- Lifecycle ---
62
+ protected onInit() {
63
+ this.subscribeTo(this.collection, () => {
64
+ this.set({ items: this.collection.items });
65
+ });
66
+ if (this.collection.length > 0) {
67
+ this.set({ items: this.collection.items });
68
+ } else {
69
+ this.load();
70
+ }
71
+ }
72
+
73
+ // --- Actions ---
74
+ async load() {
75
+ const data = await this.service.getAll(this.disposeSignal);
76
+ this.collection.reset(data);
77
+ }
78
+
79
+ // --- Setters ---
80
+ setSearch(search: string) { this.set({ search }); }
81
+ }
82
+ ```
83
+
84
+ **Section order:** Private fields → Computed getters → Lifecycle → Actions → Setters.
85
+
86
+ ## Component Pattern
87
+
88
+ ```tsx
89
+ function ItemsPage() {
90
+ const [state, vm] = useLocal(ItemsViewModel, { items: [], search: '' });
91
+ const { loading, error } = vm.async.load;
92
+
93
+ return (
94
+ <div>
95
+ <input value={state.search} onChange={e => vm.setSearch(e.target.value)} />
96
+ {loading && <Spinner />}
97
+ {error && <ErrorBanner message={error} />}
98
+ <ItemsTable items={vm.filtered} />
99
+ <p>Showing {vm.filtered.length} of {vm.total}</p>
100
+ </div>
101
+ );
102
+ }
103
+ ```
104
+
105
+ ## React Hooks
106
+
107
+ | Hook | Usage |
108
+ |------|-------|
109
+ | `useLocal(Class, ...args)` | Component-scoped, auto-init/dispose. Returns `[state, instance]`. |
110
+ | `useSingleton(Class, ...args)` | Singleton, shared state. Returns `[state, instance]`. |
111
+ | `useInstance(subscribable)` | Subscribe to existing instance. Returns `state`. |
112
+ | `useModel(factory)` | Model with `{ state, errors, valid, dirty, model }`. |
113
+ | `useField(model, key)` | Single field: `{ value, error, set }`. |
114
+ | `useEvent(source, event, handler)` | EventBus/ViewModel event subscription. |
115
+ | `useEmit(bus)` | Stable emit function. |
116
+ | `useResolve(Class)` | Resolve from Provider or singleton. |
117
+ | `useTeardown(...Classes)` | Teardown singletons on unmount. |
118
+
119
+ ## Service Pattern
120
+
121
+ ```typescript
122
+ class UserService extends Service {
123
+ async getAll(signal?: AbortSignal): Promise<User[]> {
124
+ const res = await fetch('/api/users', { signal });
125
+ if (!res.ok) throw new HttpError(res.status, res.statusText);
126
+ return res.json();
127
+ }
128
+ }
129
+ ```
130
+
131
+ ## Collection Pattern
132
+
133
+ ```typescript
134
+ class UsersCollection extends Collection<UserState> {}
135
+ // Thin subclass. Query logic in ViewModel getters.
136
+ ```
137
+
138
+ ## Model Pattern
139
+
140
+ ```typescript
141
+ class UserModel extends Model<UserFormState> {
142
+ setName(name: string) { this.set({ name }); }
143
+
144
+ protected validate(state: UserFormState) {
145
+ const errors: Partial<Record<keyof UserFormState, string>> = {};
146
+ if (!state.name.trim()) errors.name = 'Required';
147
+ return errors;
148
+ }
149
+ }
150
+ ```
151
+
152
+ ## Sharing Patterns
153
+
154
+ 1. **Pattern A** (default): Parent ViewModel passes props to presentational children
155
+ 2. **Pattern B**: Singleton ViewModel via `useSingleton` for app-wide state
156
+ 3. **Pattern C**: Separate ViewModels sharing a singleton Collection
157
+
158
+ ## ViewModel Events
159
+
160
+ ```typescript
161
+ interface SaveEvents { saved: { id: string }; error: void; }
162
+
163
+ class ItemVM extends ViewModel<State, SaveEvents> {
164
+ async save() {
165
+ const result = await this.service.save(this.state.draft, this.disposeSignal);
166
+ this.emit('saved', { id: result.id }); // protected, type-safe
167
+ }
168
+ }
169
+
170
+ // Component
171
+ useEvent(vm, 'saved', ({ id }) => toast.success(`Saved ${id}`));
172
+ ```
173
+
174
+ ## Optimistic Updates
175
+
176
+ ```typescript
177
+ async toggleStatus(id: string) {
178
+ const rollback = this.collection.optimistic(() => {
179
+ this.collection.update(id, { status: 'done' });
180
+ });
181
+ try {
182
+ await this.service.update(id, { status: 'done' }, this.disposeSignal);
183
+ } catch (e) {
184
+ if (!isAbortError(e)) rollback();
185
+ throw e;
186
+ }
187
+ }
188
+ ```
189
+
190
+ ## Error Handling Layers
191
+
192
+ 1. **Async tracking** (automatic): Write happy path, read `vm.async.method.error`
193
+ 2. **Imperative events** (explicit): try/catch + `emit()` + **re-throw**
194
+ 3. **Error classification** (services): `throw HttpError`, `classifyError()`
195
+
196
+ ## Testing
197
+
198
+ ```typescript
199
+ beforeEach(() => teardownAll());
200
+
201
+ test('example', () => {
202
+ const vm = new MyViewModel({ items: [], search: '' });
203
+ vm.init();
204
+ vm.setSearch('test');
205
+ expect(vm.filtered).toHaveLength(1);
206
+ vm.dispose();
207
+ });
208
+ ```
209
+
210
+ ## Anti-Patterns — NEVER Do These
211
+
212
+ - Loading/error flags in State → use `vm.async`
213
+ - Derived state in State → use getters
214
+ - `set()` inside a getter → infinite loop
215
+ - `useEffect` for data loading → use `onInit()`
216
+ - Multiple `useLocal` in one component → split components
217
+ - Components importing Collections/Services → only ViewModel + hooks
218
+ - Services holding state/cache → use Collections
219
+ - Swallowing errors without re-throw → breaks async tracking
220
+ - Manual try/catch for standard loads → async tracking handles it
221
+ - Two-step setters with refilter calls → getters auto-recompute
222
+ - Manual optimistic snapshot/restore → use `collection.optimistic()`
223
+ - `useState`/`useMemo`/`useCallback` in connected components → ViewModel handles it
224
+
225
+ ## Decision Framework
226
+
227
+ - Holds UI state for a component → **ViewModel**
228
+ - Single entity with validation → **Model**
229
+ - List of entities with CRUD → **Collection**
230
+ - Fetches external data → **Service**
231
+ - Cross-cutting events → **EventBus**
232
+ - Persistent connection → **Channel**
233
+ - Coordinates multiple ViewModels → **Controller** (rare)
234
+
235
+ ## Dev Mode
236
+
237
+ ```typescript
238
+ // vite.config.ts
239
+ define: { __MVC_KIT_DEV__: process.env.NODE_ENV !== 'production' }
240
+ ```
241
+
242
+ Catches: `set()` inside getter, ghost async ops, method call after dispose, reserved key override.
@@ -0,0 +1,242 @@
1
+ # mvc-kit Framework Rules
2
+
3
+ This project uses **mvc-kit**, a zero-dependency TypeScript-first reactive state management library for React. Follow these rules strictly when writing or reviewing code.
4
+
5
+ ## Core Classes
6
+
7
+ | Class | Role | Scope |
8
+ |-------|------|-------|
9
+ | `ViewModel<S, E?>` | Reactive state + computed getters + async tracking + typed events | Component-scoped (`useLocal`) |
10
+ | `Model<S>` | Entity with validation + dirty tracking + commit/rollback | Component-scoped (`useModel`) |
11
+ | `Collection<T>` | Reactive typed array, shared data cache, optimistic updates | Singleton |
12
+ | `Service` | Stateless infrastructure adapter (HTTP, storage) | Singleton |
13
+ | `EventBus<E>` | Typed pub/sub for cross-cutting events | Singleton |
14
+ | `Channel<M>` | Persistent connection (WebSocket/SSE) with auto-reconnect | Singleton |
15
+ | `Controller` | Stateless multi-ViewModel orchestrator (rare) | Component-scoped |
16
+
17
+ ## Imports
18
+
19
+ ```typescript
20
+ import { ViewModel, Model, Collection, Controller, Service, EventBus, Channel } from 'mvc-kit';
21
+ import { singleton, teardownAll, HttpError, isAbortError, classifyError } from 'mvc-kit';
22
+ import { useLocal, useSingleton, useInstance, useModel, useField, useEvent, useEmit, useResolve, useTeardown, Provider } from 'mvc-kit/react';
23
+ ```
24
+
25
+ ## Architecture Rules
26
+
27
+ 1. **State = source of truth only.** No derived values, no loading/error flags in state interfaces.
28
+ 2. **Derived values are `get` accessors** on the ViewModel. Auto-memoized after `init()`.
29
+ 3. **Async status is automatic.** After `init()`, `vm.async.methodName` returns `{ loading, error, errorCode }`. Never store loading/error in state.
30
+ 4. **One ViewModel per component** via `useLocal`. No `useEffect` for data loading — use `onInit()`.
31
+ 5. **No `useState`/`useMemo`/`useCallback`** — the ViewModel is the hook.
32
+ 6. **Components are declarative.** Read `state.x` for raw, `vm.x` for computed, `vm.async.x` for loading/error.
33
+ 7. **Collections are encapsulated.** ViewModels subscribe via `subscribeTo()` in `onInit()`. Components never import Collections.
34
+ 8. **Services are stateless.** Accept `AbortSignal`, throw `HttpError`, no knowledge of ViewModels.
35
+ 9. **Pass `this.disposeSignal`** to every async call.
36
+ 10. **Lifecycle**: `construct → init() → use → dispose()`. Hooks auto-call `init()`.
37
+
38
+ ## ViewModel Pattern
39
+
40
+ ```typescript
41
+ interface ItemState {
42
+ items: Item[];
43
+ search: string;
44
+ }
45
+
46
+ class ItemsViewModel extends ViewModel<ItemState> {
47
+ // --- Private fields ---
48
+ private service = singleton(ItemService);
49
+ private collection = singleton(ItemsCollection);
50
+
51
+ // --- Computed getters ---
52
+ get filtered(): Item[] {
53
+ const { items, search } = this.state;
54
+ if (!search) return items;
55
+ const q = search.toLowerCase();
56
+ return items.filter(i => i.name.toLowerCase().includes(q));
57
+ }
58
+
59
+ get total(): number { return this.state.items.length; }
60
+
61
+ // --- Lifecycle ---
62
+ protected onInit() {
63
+ this.subscribeTo(this.collection, () => {
64
+ this.set({ items: this.collection.items });
65
+ });
66
+ if (this.collection.length > 0) {
67
+ this.set({ items: this.collection.items });
68
+ } else {
69
+ this.load();
70
+ }
71
+ }
72
+
73
+ // --- Actions ---
74
+ async load() {
75
+ const data = await this.service.getAll(this.disposeSignal);
76
+ this.collection.reset(data);
77
+ }
78
+
79
+ // --- Setters ---
80
+ setSearch(search: string) { this.set({ search }); }
81
+ }
82
+ ```
83
+
84
+ **Section order:** Private fields → Computed getters → Lifecycle → Actions → Setters.
85
+
86
+ ## Component Pattern
87
+
88
+ ```tsx
89
+ function ItemsPage() {
90
+ const [state, vm] = useLocal(ItemsViewModel, { items: [], search: '' });
91
+ const { loading, error } = vm.async.load;
92
+
93
+ return (
94
+ <div>
95
+ <input value={state.search} onChange={e => vm.setSearch(e.target.value)} />
96
+ {loading && <Spinner />}
97
+ {error && <ErrorBanner message={error} />}
98
+ <ItemsTable items={vm.filtered} />
99
+ <p>Showing {vm.filtered.length} of {vm.total}</p>
100
+ </div>
101
+ );
102
+ }
103
+ ```
104
+
105
+ ## React Hooks
106
+
107
+ | Hook | Usage |
108
+ |------|-------|
109
+ | `useLocal(Class, ...args)` | Component-scoped, auto-init/dispose. Returns `[state, instance]`. |
110
+ | `useSingleton(Class, ...args)` | Singleton, shared state. Returns `[state, instance]`. |
111
+ | `useInstance(subscribable)` | Subscribe to existing instance. Returns `state`. |
112
+ | `useModel(factory)` | Model with `{ state, errors, valid, dirty, model }`. |
113
+ | `useField(model, key)` | Single field: `{ value, error, set }`. |
114
+ | `useEvent(source, event, handler)` | EventBus/ViewModel event subscription. |
115
+ | `useEmit(bus)` | Stable emit function. |
116
+ | `useResolve(Class)` | Resolve from Provider or singleton. |
117
+ | `useTeardown(...Classes)` | Teardown singletons on unmount. |
118
+
119
+ ## Service Pattern
120
+
121
+ ```typescript
122
+ class UserService extends Service {
123
+ async getAll(signal?: AbortSignal): Promise<User[]> {
124
+ const res = await fetch('/api/users', { signal });
125
+ if (!res.ok) throw new HttpError(res.status, res.statusText);
126
+ return res.json();
127
+ }
128
+ }
129
+ ```
130
+
131
+ ## Collection Pattern
132
+
133
+ ```typescript
134
+ class UsersCollection extends Collection<UserState> {}
135
+ // Thin subclass. Query logic in ViewModel getters.
136
+ ```
137
+
138
+ ## Model Pattern
139
+
140
+ ```typescript
141
+ class UserModel extends Model<UserFormState> {
142
+ setName(name: string) { this.set({ name }); }
143
+
144
+ protected validate(state: UserFormState) {
145
+ const errors: Partial<Record<keyof UserFormState, string>> = {};
146
+ if (!state.name.trim()) errors.name = 'Required';
147
+ return errors;
148
+ }
149
+ }
150
+ ```
151
+
152
+ ## Sharing Patterns
153
+
154
+ 1. **Pattern A** (default): Parent ViewModel passes props to presentational children
155
+ 2. **Pattern B**: Singleton ViewModel via `useSingleton` for app-wide state
156
+ 3. **Pattern C**: Separate ViewModels sharing a singleton Collection
157
+
158
+ ## ViewModel Events
159
+
160
+ ```typescript
161
+ interface SaveEvents { saved: { id: string }; error: void; }
162
+
163
+ class ItemVM extends ViewModel<State, SaveEvents> {
164
+ async save() {
165
+ const result = await this.service.save(this.state.draft, this.disposeSignal);
166
+ this.emit('saved', { id: result.id }); // protected, type-safe
167
+ }
168
+ }
169
+
170
+ // Component
171
+ useEvent(vm, 'saved', ({ id }) => toast.success(`Saved ${id}`));
172
+ ```
173
+
174
+ ## Optimistic Updates
175
+
176
+ ```typescript
177
+ async toggleStatus(id: string) {
178
+ const rollback = this.collection.optimistic(() => {
179
+ this.collection.update(id, { status: 'done' });
180
+ });
181
+ try {
182
+ await this.service.update(id, { status: 'done' }, this.disposeSignal);
183
+ } catch (e) {
184
+ if (!isAbortError(e)) rollback();
185
+ throw e;
186
+ }
187
+ }
188
+ ```
189
+
190
+ ## Error Handling Layers
191
+
192
+ 1. **Async tracking** (automatic): Write happy path, read `vm.async.method.error`
193
+ 2. **Imperative events** (explicit): try/catch + `emit()` + **re-throw**
194
+ 3. **Error classification** (services): `throw HttpError`, `classifyError()`
195
+
196
+ ## Testing
197
+
198
+ ```typescript
199
+ beforeEach(() => teardownAll());
200
+
201
+ test('example', () => {
202
+ const vm = new MyViewModel({ items: [], search: '' });
203
+ vm.init();
204
+ vm.setSearch('test');
205
+ expect(vm.filtered).toHaveLength(1);
206
+ vm.dispose();
207
+ });
208
+ ```
209
+
210
+ ## Anti-Patterns — NEVER Do These
211
+
212
+ - Loading/error flags in State → use `vm.async`
213
+ - Derived state in State → use getters
214
+ - `set()` inside a getter → infinite loop
215
+ - `useEffect` for data loading → use `onInit()`
216
+ - Multiple `useLocal` in one component → split components
217
+ - Components importing Collections/Services → only ViewModel + hooks
218
+ - Services holding state/cache → use Collections
219
+ - Swallowing errors without re-throw → breaks async tracking
220
+ - Manual try/catch for standard loads → async tracking handles it
221
+ - Two-step setters with refilter calls → getters auto-recompute
222
+ - Manual optimistic snapshot/restore → use `collection.optimistic()`
223
+ - `useState`/`useMemo`/`useCallback` in connected components → ViewModel handles it
224
+
225
+ ## Decision Framework
226
+
227
+ - Holds UI state for a component → **ViewModel**
228
+ - Single entity with validation → **Model**
229
+ - List of entities with CRUD → **Collection**
230
+ - Fetches external data → **Service**
231
+ - Cross-cutting events → **EventBus**
232
+ - Persistent connection → **Channel**
233
+ - Coordinates multiple ViewModels → **Controller** (rare)
234
+
235
+ ## Dev Mode
236
+
237
+ ```typescript
238
+ // vite.config.ts
239
+ define: { __MVC_KIT_DEV__: process.env.NODE_ENV !== 'production' }
240
+ ```
241
+
242
+ Catches: `set()` inside getter, ghost async ops, method call after dispose, reserved key override.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mvc-kit",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Zero-magic, class-based reactive ViewModel library",
5
5
  "type": "module",
6
6
  "main": "./dist/mvc-kit.cjs",
@@ -29,8 +29,12 @@
29
29
  }
30
30
  },
31
31
  "files": [
32
- "dist"
32
+ "dist",
33
+ "agent-config"
33
34
  ],
35
+ "bin": {
36
+ "mvc-kit-setup": "./agent-config/bin/setup.mjs"
37
+ },
34
38
  "sideEffects": false,
35
39
  "scripts": {
36
40
  "dev": "vite",