mvc-kit 2.12.0 → 2.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/agent-config/bin/postinstall.mjs +5 -3
  2. package/agent-config/bin/setup.mjs +3 -4
  3. package/agent-config/claude-code/agents/mvc-kit-architect.md +14 -0
  4. package/agent-config/claude-code/skills/guide/api-reference.md +24 -2
  5. package/agent-config/lib/install-claude.mjs +19 -33
  6. package/dist/Model.cjs +9 -1
  7. package/dist/Model.cjs.map +1 -1
  8. package/dist/Model.d.ts +1 -1
  9. package/dist/Model.d.ts.map +1 -1
  10. package/dist/Model.js +9 -1
  11. package/dist/Model.js.map +1 -1
  12. package/dist/ViewModel.cjs +9 -1
  13. package/dist/ViewModel.cjs.map +1 -1
  14. package/dist/ViewModel.d.ts +1 -1
  15. package/dist/ViewModel.d.ts.map +1 -1
  16. package/dist/ViewModel.js +9 -1
  17. package/dist/ViewModel.js.map +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/mvc-kit.cjs +3 -0
  21. package/dist/mvc-kit.cjs.map +1 -1
  22. package/dist/mvc-kit.js +3 -0
  23. package/dist/mvc-kit.js.map +1 -1
  24. package/dist/produceDraft.cjs +105 -0
  25. package/dist/produceDraft.cjs.map +1 -0
  26. package/dist/produceDraft.d.ts +19 -0
  27. package/dist/produceDraft.d.ts.map +1 -0
  28. package/dist/produceDraft.js +105 -0
  29. package/dist/produceDraft.js.map +1 -0
  30. package/package.json +4 -2
  31. package/src/Channel.md +408 -0
  32. package/src/Channel.test.ts +957 -0
  33. package/src/Channel.ts +429 -0
  34. package/src/Collection.md +533 -0
  35. package/src/Collection.test.ts +1559 -0
  36. package/src/Collection.ts +653 -0
  37. package/src/Controller.md +306 -0
  38. package/src/Controller.test.ts +380 -0
  39. package/src/Controller.ts +90 -0
  40. package/src/EventBus.md +308 -0
  41. package/src/EventBus.test.ts +295 -0
  42. package/src/EventBus.ts +110 -0
  43. package/src/Feed.md +218 -0
  44. package/src/Feed.test.ts +442 -0
  45. package/src/Feed.ts +101 -0
  46. package/src/Model.md +524 -0
  47. package/src/Model.test.ts +642 -0
  48. package/src/Model.ts +260 -0
  49. package/src/Pagination.md +168 -0
  50. package/src/Pagination.test.ts +244 -0
  51. package/src/Pagination.ts +92 -0
  52. package/src/Pending.md +380 -0
  53. package/src/Pending.test.ts +1719 -0
  54. package/src/Pending.ts +390 -0
  55. package/src/PersistentCollection.md +183 -0
  56. package/src/PersistentCollection.test.ts +649 -0
  57. package/src/PersistentCollection.ts +375 -0
  58. package/src/Resource.ViewModel.test.ts +503 -0
  59. package/src/Resource.md +239 -0
  60. package/src/Resource.test.ts +786 -0
  61. package/src/Resource.ts +231 -0
  62. package/src/Selection.md +155 -0
  63. package/src/Selection.test.ts +326 -0
  64. package/src/Selection.ts +117 -0
  65. package/src/Service.md +440 -0
  66. package/src/Service.test.ts +241 -0
  67. package/src/Service.ts +72 -0
  68. package/src/Sorting.md +170 -0
  69. package/src/Sorting.test.ts +334 -0
  70. package/src/Sorting.ts +135 -0
  71. package/src/Trackable.md +166 -0
  72. package/src/Trackable.test.ts +236 -0
  73. package/src/Trackable.ts +129 -0
  74. package/src/ViewModel.async.test.ts +813 -0
  75. package/src/ViewModel.derived.test.ts +1583 -0
  76. package/src/ViewModel.md +1111 -0
  77. package/src/ViewModel.test.ts +1236 -0
  78. package/src/ViewModel.ts +800 -0
  79. package/src/bindPublicMethods.test.ts +126 -0
  80. package/src/bindPublicMethods.ts +48 -0
  81. package/src/env.d.ts +5 -0
  82. package/src/errors.test.ts +155 -0
  83. package/src/errors.ts +133 -0
  84. package/src/index.ts +49 -0
  85. package/src/produceDraft.md +90 -0
  86. package/src/produceDraft.test.ts +394 -0
  87. package/src/produceDraft.ts +168 -0
  88. package/src/react/components/CardList.md +97 -0
  89. package/src/react/components/CardList.test.tsx +142 -0
  90. package/src/react/components/CardList.tsx +68 -0
  91. package/src/react/components/DataTable.md +179 -0
  92. package/src/react/components/DataTable.test.tsx +599 -0
  93. package/src/react/components/DataTable.tsx +267 -0
  94. package/src/react/components/InfiniteScroll.md +116 -0
  95. package/src/react/components/InfiniteScroll.test.tsx +218 -0
  96. package/src/react/components/InfiniteScroll.tsx +70 -0
  97. package/src/react/components/types.ts +90 -0
  98. package/src/react/derived.test.tsx +261 -0
  99. package/src/react/guards.ts +24 -0
  100. package/src/react/index.ts +40 -0
  101. package/src/react/provider.test.tsx +143 -0
  102. package/src/react/provider.tsx +55 -0
  103. package/src/react/strict-mode.test.tsx +266 -0
  104. package/src/react/types.ts +25 -0
  105. package/src/react/use-event-bus.md +214 -0
  106. package/src/react/use-event-bus.test.tsx +168 -0
  107. package/src/react/use-event-bus.ts +40 -0
  108. package/src/react/use-instance.md +204 -0
  109. package/src/react/use-instance.test.tsx +350 -0
  110. package/src/react/use-instance.ts +60 -0
  111. package/src/react/use-local.md +457 -0
  112. package/src/react/use-local.rapid-remount.test.tsx +503 -0
  113. package/src/react/use-local.test.tsx +692 -0
  114. package/src/react/use-local.ts +165 -0
  115. package/src/react/use-model.md +364 -0
  116. package/src/react/use-model.test.tsx +394 -0
  117. package/src/react/use-model.ts +161 -0
  118. package/src/react/use-singleton.md +415 -0
  119. package/src/react/use-singleton.test.tsx +296 -0
  120. package/src/react/use-singleton.ts +69 -0
  121. package/src/react/use-subscribe-only.ts +39 -0
  122. package/src/react/use-teardown.md +169 -0
  123. package/src/react/use-teardown.test.tsx +86 -0
  124. package/src/react/use-teardown.ts +27 -0
  125. package/src/react-native/NativeCollection.test.ts +250 -0
  126. package/src/react-native/NativeCollection.ts +138 -0
  127. package/src/react-native/index.ts +1 -0
  128. package/src/singleton.md +310 -0
  129. package/src/singleton.test.ts +204 -0
  130. package/src/singleton.ts +70 -0
  131. package/src/types.ts +70 -0
  132. package/src/walkPrototypeChain.ts +22 -0
  133. package/src/web/IndexedDBCollection.test.ts +235 -0
  134. package/src/web/IndexedDBCollection.ts +66 -0
  135. package/src/web/WebStorageCollection.test.ts +214 -0
  136. package/src/web/WebStorageCollection.ts +116 -0
  137. package/src/web/idb.ts +184 -0
  138. package/src/web/index.ts +2 -0
  139. package/src/wrapAsyncMethods.ts +249 -0
@@ -0,0 +1,117 @@
1
+ import { Trackable } from './Trackable';
2
+
3
+ /**
4
+ * Key-based selection set with toggle and select-all support.
5
+ * Tracks which items are selected by their key (id).
6
+ * Subscribable — auto-tracked when used as a ViewModel property.
7
+ */
8
+ export class Selection<K extends string | number = string | number> extends Trackable {
9
+ private _selected: Set<K> = new Set();
10
+ private _readonlyView: ReadonlySet<K> = this._selected;
11
+
12
+ constructor() {
13
+ super();
14
+ }
15
+
16
+ // ── Readable state ──
17
+
18
+ /** Read-only view of currently selected keys. */
19
+ get selected(): ReadonlySet<K> {
20
+ return this._readonlyView;
21
+ }
22
+
23
+ /** Number of currently selected items. */
24
+ get count(): number {
25
+ return this._selected.size;
26
+ }
27
+
28
+ /** Whether any items are currently selected. */
29
+ get hasSelection(): boolean {
30
+ return this._selected.size > 0;
31
+ }
32
+
33
+ // ── Query ──
34
+
35
+ /** Check whether a specific key is selected. */
36
+ isSelected(key: K): boolean {
37
+ return this._selected.has(key);
38
+ }
39
+
40
+ // ── Actions ──
41
+
42
+ /** Toggle a key's selection state (select if unselected, deselect if selected). */
43
+ toggle(key: K): void {
44
+ if (this._selected.has(key)) {
45
+ this._selected.delete(key);
46
+ } else {
47
+ this._selected.add(key);
48
+ }
49
+ this._publish();
50
+ }
51
+
52
+ /** Add one or more keys to the selection. */
53
+ select(...keys: K[]): void {
54
+ let changed = false;
55
+ for (const key of keys) {
56
+ if (!this._selected.has(key)) {
57
+ this._selected.add(key);
58
+ changed = true;
59
+ }
60
+ }
61
+ if (changed) this._publish();
62
+ }
63
+
64
+ /** Remove one or more keys from the selection. */
65
+ deselect(...keys: K[]): void {
66
+ let changed = false;
67
+ for (const key of keys) {
68
+ if (this._selected.has(key)) {
69
+ this._selected.delete(key);
70
+ changed = true;
71
+ }
72
+ }
73
+ if (changed) this._publish();
74
+ }
75
+
76
+ /** If all selected → deselect all, else select all. */
77
+ toggleAll(allKeys: K[]): void {
78
+ const allSelected = allKeys.length > 0 && allKeys.every(k => this._selected.has(k));
79
+ if (allSelected) {
80
+ this._selected.clear();
81
+ } else {
82
+ for (const key of allKeys) this._selected.add(key);
83
+ }
84
+ this._publish();
85
+ }
86
+
87
+ /** Replace the entire selection atomically. Single notification. */
88
+ set(...keys: K[]): void {
89
+ // Check if anything actually changed
90
+ if (keys.length === this._selected.size && keys.every(k => this._selected.has(k))) return;
91
+ this._selected.clear();
92
+ for (const key of keys) this._selected.add(key);
93
+ this._publish();
94
+ }
95
+
96
+ /** Remove all items from the selection. */
97
+ clear(): void {
98
+ if (this._selected.size === 0) return;
99
+ this._selected.clear();
100
+ this._publish();
101
+ }
102
+
103
+ // ── Utility ──
104
+
105
+ /** Filter an array to only items whose key is in the selection. */
106
+ selectedFrom<T>(items: T[], keyOf: (item: T) => K): T[] {
107
+ return items.filter(item => this._selected.has(keyOf(item)));
108
+ }
109
+
110
+ // ── Internal ──
111
+
112
+ private _publish(): void {
113
+ // Replace readonlyView so reference equality changes (needed for React)
114
+ this._readonlyView = new Set(this._selected);
115
+ this.notify();
116
+ }
117
+ }
package/src/Service.md ADDED
@@ -0,0 +1,440 @@
1
+ # Service
2
+
3
+ A base class for non-reactive infrastructure adapters. Services encapsulate external dependencies — APIs, storage, third-party SDKs — behind clean, stateless interfaces. They sit at the bottom of the dependency graph: ViewModels and Controllers depend on Services, but Services depend on nothing in mvc-kit.
4
+
5
+ ---
6
+
7
+ ## When to Use
8
+
9
+ | Question | Use |
10
+ |---|---|
11
+ | Does it wrap raw `fetch()` with `HttpError` and response parsing? | **Service** |
12
+ | Does it wrap a third-party SDK behind a clean interface? | **Service** |
13
+ | Does it manage auth headers, retries, or compose multiple HTTP calls? | **Service** |
14
+ | Does it hold UI state, computed properties, or actions for a component? | **ViewModel** |
15
+ | Does it hold a list of entities with CRUD operations? | **Collection** |
16
+
17
+ If the class needs reactive state, getters, or async tracking — it's a ViewModel, not a Service.
18
+
19
+ **When NOT to use a Service:** If you already have a typed API client (RPC client, tRPC, GraphQL codegen, OpenAPI generated client) that handles error types, response parsing, and signal passing, don't wrap it in a Service. Call it directly from your Resource or ViewModel. A Service that only destructures and re-returns the client's response is a pass-through that adds lines without value.
20
+
21
+ ---
22
+
23
+ ## Subclass Contract
24
+
25
+ Service is `abstract`. Extend it and add your methods:
26
+
27
+ ```typescript
28
+ import { Service, HttpError } from 'mvc-kit';
29
+
30
+ export class UserService extends Service {
31
+ async getAll(signal?: AbortSignal): Promise<UserState[]> {
32
+ const res = await fetch('/api/users', { signal });
33
+ if (!res.ok) throw new HttpError(res.status, res.statusText);
34
+ return res.json();
35
+ }
36
+
37
+ async update(id: string, data: Partial<UserState>, signal?: AbortSignal): Promise<UserState> {
38
+ const res = await fetch(`/api/users/${id}`, {
39
+ method: 'PATCH',
40
+ headers: { 'Content-Type': 'application/json' },
41
+ body: JSON.stringify(data),
42
+ signal,
43
+ });
44
+ if (!res.ok) throw new HttpError(res.status, res.statusText);
45
+ return res.json();
46
+ }
47
+ }
48
+ ```
49
+
50
+ ---
51
+
52
+ ## What Service Provides
53
+
54
+ Service is deliberately minimal. It provides lifecycle management and cancellation — nothing more.
55
+
56
+ | Feature | Included |
57
+ |---|---|
58
+ | `init()` / `dispose()` lifecycle | Yes |
59
+ | `onInit()` / `onDispose()` hooks | Yes |
60
+ | `disposeSignal` (AbortSignal) | Yes |
61
+ | `addCleanup(fn)` | Yes |
62
+ | `initialized` / `disposed` flags | Yes |
63
+ | Reactive state | No |
64
+ | Computed getters | No |
65
+ | Async tracking | No |
66
+ | Events / emit | No |
67
+ | `subscribeTo` | No |
68
+
69
+ ---
70
+
71
+ ## API
72
+
73
+ ### `init(): void | Promise<void>`
74
+
75
+ Initializes the Service. Calls `onInit()` if defined. Idempotent — calling it multiple times only runs `onInit()` once. No-op if already disposed.
76
+
77
+ ```typescript
78
+ const service = new UserService();
79
+ service.init();
80
+ service.init(); // no-op
81
+ ```
82
+
83
+ Supports async initialization:
84
+
85
+ ```typescript
86
+ class ConfigService extends Service {
87
+ private config: AppConfig | null = null;
88
+
89
+ protected async onInit() {
90
+ const res = await fetch('/api/config');
91
+ this.config = await res.json();
92
+ }
93
+
94
+ getConfig(): AppConfig {
95
+ return this.config!;
96
+ }
97
+ }
98
+
99
+ const service = new ConfigService();
100
+ await service.init();
101
+ ```
102
+
103
+ ### `dispose(): void`
104
+
105
+ Tears down the Service. In order:
106
+ 1. Sets `disposed` to `true`
107
+ 2. Aborts `disposeSignal`
108
+ 3. Runs all registered cleanups
109
+ 4. Calls `onDispose()`
110
+
111
+ Idempotent — safe to call multiple times. `onDispose()` only runs once.
112
+
113
+ ```typescript
114
+ service.dispose();
115
+ service.dispose(); // no-op
116
+ ```
117
+
118
+ ### `disposeSignal: AbortSignal`
119
+
120
+ A lazily-created AbortSignal that aborts when the Service is disposed. Pass it to `fetch()` or other cancellable APIs to cancel in-flight operations on teardown.
121
+
122
+ ```typescript
123
+ class UserService extends Service {
124
+ async getAll(signal?: AbortSignal): Promise<UserState[]> {
125
+ // Compose with disposeSignal for double protection
126
+ const res = await fetch('/api/users', {
127
+ signal: signal ?? this.disposeSignal,
128
+ });
129
+ return res.json();
130
+ }
131
+ }
132
+ ```
133
+
134
+ The signal is:
135
+ - **Lazy** — zero cost if never accessed. No AbortController is allocated unless you read `disposeSignal`.
136
+ - **Stable** — returns the same signal on every access.
137
+ - **Aborted before `onDispose()` runs** — cleanup code can check `this.disposeSignal.aborted` and it will be `true`.
138
+
139
+ ### `addCleanup(fn: () => void): void` (protected)
140
+
141
+ Registers a cleanup function that runs on dispose. Use this for teardown that the base class can't handle automatically.
142
+
143
+ ```typescript
144
+ class PollingService extends Service {
145
+ private intervalId?: ReturnType<typeof setInterval>;
146
+
147
+ protected onInit() {
148
+ this.intervalId = setInterval(() => this.poll(), 5000);
149
+ this.addCleanup(() => clearInterval(this.intervalId));
150
+ }
151
+
152
+ private async poll() { /* ... */ }
153
+ }
154
+ ```
155
+
156
+ ### `onInit?(): void | Promise<void>` (protected)
157
+
158
+ Override to run setup logic when `init()` is called. This is where you open connections, start timers, or perform initial configuration.
159
+
160
+ ### `onDispose?(): void` (protected)
161
+
162
+ Override to run teardown logic when `dispose()` is called. Runs after the signal is aborted and cleanups have fired.
163
+
164
+ ```typescript
165
+ class StorageService extends Service {
166
+ private data = new Map<string, string>();
167
+
168
+ protected onDispose() {
169
+ this.data.clear();
170
+ }
171
+ }
172
+ ```
173
+
174
+ ---
175
+
176
+ ## Lifecycle
177
+
178
+ ```
179
+ new Service() → disposed: false, initialized: false
180
+
181
+ init() → initialized: true, onInit() called
182
+
183
+ dispose() → disposed: true
184
+ 1. abort signal
185
+ 2. run cleanups
186
+ 3. onDispose() called
187
+ ```
188
+
189
+ - `init()` after `dispose()` is a no-op — the Service stays uninitialized.
190
+ - `dispose()` before `init()` works — the Service is disposed without ever initializing.
191
+
192
+ ---
193
+
194
+ ## Singleton Usage
195
+
196
+ Services are almost always singletons. They're stateless infrastructure — there's no reason to have two instances of the same service.
197
+
198
+ ```typescript
199
+ import { singleton, teardown, teardownAll } from 'mvc-kit';
200
+
201
+ // Resolved inside ViewModels as property initializers
202
+ class LocationsViewModel extends ViewModel<State> {
203
+ private service = singleton(LocationService);
204
+ }
205
+
206
+ // Same instance on repeated calls
207
+ singleton(LocationService) === singleton(LocationService); // true
208
+
209
+ // Teardown disposes and removes from registry
210
+ teardown(LocationService);
211
+
212
+ // Or tear down everything at once (common in test setup)
213
+ teardownAll();
214
+ ```
215
+
216
+ ---
217
+
218
+ ## Error Handling
219
+
220
+ ### Throw HttpError for HTTP Failures
221
+
222
+ `HttpError` is a typed error class that carries the HTTP status code. The ViewModel's async tracking and `classifyError()` use it to produce canonical error codes.
223
+
224
+ ```typescript
225
+ import { HttpError } from 'mvc-kit';
226
+
227
+ class UserService extends Service {
228
+ async getById(id: string, signal?: AbortSignal): Promise<UserState> {
229
+ const res = await fetch(`/api/users/${id}`, { signal });
230
+ if (!res.ok) throw new HttpError(res.status, res.statusText);
231
+ return res.json();
232
+ }
233
+ }
234
+ ```
235
+
236
+ When a ViewModel calls this method, async tracking automatically classifies the error:
237
+
238
+ | Status | `errorCode` |
239
+ |---|---|
240
+ | 401 | `'unauthorized'` |
241
+ | 403 | `'forbidden'` |
242
+ | 404 | `'not_found'` |
243
+ | 422 | `'validation'` |
244
+ | 429 | `'rate_limited'` |
245
+ | 500+ | `'server_error'` |
246
+ | Fetch failure | `'network'` |
247
+ | AbortError | Silently swallowed |
248
+
249
+ ### Accept AbortSignal Parameters
250
+
251
+ Every async method should accept an optional `AbortSignal`. This lets ViewModels pass `this.disposeSignal` to cancel in-flight requests when components unmount.
252
+
253
+ ```typescript
254
+ async getAll(signal?: AbortSignal): Promise<UserState[]> {
255
+ const res = await fetch('/api/users', { signal });
256
+ if (!res.ok) throw new HttpError(res.status, res.statusText);
257
+ return res.json();
258
+ }
259
+ ```
260
+
261
+ The ViewModel passes its signal:
262
+
263
+ ```typescript
264
+ async load() {
265
+ const data = await this.service.getAll(this.disposeSignal);
266
+ this.collection.reset(data);
267
+ }
268
+ ```
269
+
270
+ ---
271
+
272
+ ## Examples
273
+
274
+ ### Basic REST Service
275
+
276
+ ```typescript
277
+ class LocationService extends Service {
278
+ async getAll(signal?: AbortSignal): Promise<LocationState[]> {
279
+ const res = await fetch('/api/locations', { signal });
280
+ if (!res.ok) throw new HttpError(res.status, res.statusText);
281
+ return res.json();
282
+ }
283
+
284
+ async create(data: Omit<LocationState, 'id'>, signal?: AbortSignal): Promise<LocationState> {
285
+ const res = await fetch('/api/locations', {
286
+ method: 'POST',
287
+ headers: { 'Content-Type': 'application/json' },
288
+ body: JSON.stringify(data),
289
+ signal,
290
+ });
291
+ if (!res.ok) throw new HttpError(res.status, res.statusText);
292
+ return res.json();
293
+ }
294
+
295
+ async delete(id: string, signal?: AbortSignal): Promise<void> {
296
+ const res = await fetch(`/api/locations/${id}`, { method: 'DELETE', signal });
297
+ if (!res.ok) throw new HttpError(res.status, res.statusText);
298
+ }
299
+ }
300
+ ```
301
+
302
+ ### Service with Initialization
303
+
304
+ ```typescript
305
+ class AuthService extends Service {
306
+ private token: string | null = null;
307
+
308
+ protected async onInit() {
309
+ this.token = localStorage.getItem('auth_token');
310
+ }
311
+
312
+ private get headers(): HeadersInit {
313
+ return this.token
314
+ ? { Authorization: `Bearer ${this.token}`, 'Content-Type': 'application/json' }
315
+ : { 'Content-Type': 'application/json' };
316
+ }
317
+
318
+ async getCurrentUser(signal?: AbortSignal): Promise<UserState> {
319
+ const res = await fetch('/api/me', { headers: this.headers, signal });
320
+ if (!res.ok) throw new HttpError(res.status, res.statusText);
321
+ return res.json();
322
+ }
323
+
324
+ setToken(token: string) {
325
+ this.token = token;
326
+ localStorage.setItem('auth_token', token);
327
+ }
328
+
329
+ protected onDispose() {
330
+ this.token = null;
331
+ }
332
+ }
333
+ ```
334
+
335
+ ### Service with Cleanup
336
+
337
+ ```typescript
338
+ class PollingService extends Service {
339
+ private intervalId?: ReturnType<typeof setInterval>;
340
+
341
+ protected onInit() {
342
+ this.intervalId = setInterval(() => this.healthCheck(), 30_000);
343
+ this.addCleanup(() => clearInterval(this.intervalId));
344
+ }
345
+
346
+ private async healthCheck() {
347
+ try {
348
+ await fetch('/api/health', { signal: this.disposeSignal });
349
+ } catch {
350
+ // swallow — health checks are best-effort
351
+ }
352
+ }
353
+ }
354
+ ```
355
+
356
+ ---
357
+
358
+ ## Best Practices
359
+
360
+ 1. **Keep Services stateless.** Don't cache data between calls — that's a Collection's job. A Service transforms requests and responses; it doesn't own data.
361
+
362
+ 2. **Accept `AbortSignal` on every async method.** This lets ViewModels cancel in-flight requests via `disposeSignal` when components unmount.
363
+
364
+ 3. **Throw `HttpError` for HTTP failures.** It carries the status code, which `classifyError()` maps to canonical error codes for the ViewModel's async tracking.
365
+
366
+ 4. **Use `singleton()` resolution.** Services are stateless infrastructure — always resolve via `singleton(MyService)` inside ViewModel property initializers.
367
+
368
+ 5. **Don't import ViewModels or Collections.** Services sit at the bottom of the dependency graph. They don't know about the rest of mvc-kit. Data flows up: Service → ViewModel → Component.
369
+
370
+ 6. **Use `onInit()` for one-time setup.** Loading configuration, reading from localStorage, or establishing non-connection infrastructure.
371
+
372
+ 7. **Use `addCleanup()` for timers and external subscriptions.** Anything started in `onInit()` or service methods that needs teardown.
373
+
374
+ ## Anti-Patterns
375
+
376
+ ```typescript
377
+ // Don't cache data in a Service — use a Collection
378
+ class BadService extends Service {
379
+ private cache: UserState[] = []; // belongs in UsersCollection
380
+ async getAll() {
381
+ if (this.cache.length) return this.cache;
382
+ this.cache = await fetch('/api/users').then(r => r.json());
383
+ return this.cache;
384
+ }
385
+ }
386
+
387
+ // Don't make Services reactive — use a ViewModel
388
+ class BadReactiveService extends Service {
389
+ state = { loading: false }; // Services have no reactive state
390
+ listeners = new Set<() => void>(); // Services have no subscriptions
391
+ }
392
+
393
+ // Don't forget AbortSignal parameters
394
+ class BadService extends Service {
395
+ async getAll(): Promise<UserState[]> {
396
+ const res = await fetch('/api/users'); // uncancellable — leaks on unmount
397
+ return res.json();
398
+ }
399
+ }
400
+
401
+ // Don't throw plain Errors for HTTP failures
402
+ class BadService extends Service {
403
+ async getAll(signal?: AbortSignal) {
404
+ const res = await fetch('/api/users', { signal });
405
+ if (!res.ok) throw new Error('Request failed'); // loses status code
406
+ return res.json();
407
+ }
408
+ }
409
+
410
+ // Don't create a pass-through Service for a typed API client
411
+ // BAD — Service just proxies an existing client
412
+ class ArticleService extends Service {
413
+ async get(id: string, signal?: AbortSignal) {
414
+ const { result } = await rpcClient.articles.get({ id }, { abortSignal: signal });
415
+ return result;
416
+ }
417
+ async create(input: CreateInput, signal?: AbortSignal) {
418
+ const { result } = await rpcClient.articles.create(input, { abortSignal: signal });
419
+ return result;
420
+ }
421
+ }
422
+ // GOOD — Resource calls the API client directly
423
+ class ArticleResource extends Resource<Article> {
424
+ async loadById(id: string) {
425
+ const { result } = await rpcClient.articles.get({ id }, { abortSignal: this.disposeSignal });
426
+ this.upsert(result);
427
+ }
428
+ }
429
+ ```
430
+
431
+ > See `BEST_PRACTICES.md` for prescribed usage patterns and dependency flow rules.
432
+
433
+ ## Method Binding
434
+
435
+ All public methods are auto-bound in the constructor. You can pass them point-free as callbacks without losing `this` context:
436
+
437
+ ```tsx
438
+ const { fetchData } = service;
439
+ onMount(fetchData); // point-free works
440
+ ```