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,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
+ ```