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.
- package/agent-config/bin/postinstall.mjs +5 -3
- package/agent-config/bin/setup.mjs +3 -4
- package/agent-config/claude-code/agents/mvc-kit-architect.md +14 -0
- package/agent-config/claude-code/skills/guide/api-reference.md +24 -2
- package/agent-config/lib/install-claude.mjs +19 -33
- package/dist/Model.cjs +9 -1
- package/dist/Model.cjs.map +1 -1
- package/dist/Model.d.ts +1 -1
- package/dist/Model.d.ts.map +1 -1
- package/dist/Model.js +9 -1
- package/dist/Model.js.map +1 -1
- package/dist/ViewModel.cjs +9 -1
- package/dist/ViewModel.cjs.map +1 -1
- package/dist/ViewModel.d.ts +1 -1
- package/dist/ViewModel.d.ts.map +1 -1
- package/dist/ViewModel.js +9 -1
- package/dist/ViewModel.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/mvc-kit.cjs +3 -0
- package/dist/mvc-kit.cjs.map +1 -1
- package/dist/mvc-kit.js +3 -0
- package/dist/mvc-kit.js.map +1 -1
- package/dist/produceDraft.cjs +105 -0
- package/dist/produceDraft.cjs.map +1 -0
- package/dist/produceDraft.d.ts +19 -0
- package/dist/produceDraft.d.ts.map +1 -0
- package/dist/produceDraft.js +105 -0
- package/dist/produceDraft.js.map +1 -0
- package/package.json +4 -2
- package/src/Channel.md +408 -0
- package/src/Channel.test.ts +957 -0
- package/src/Channel.ts +429 -0
- package/src/Collection.md +533 -0
- package/src/Collection.test.ts +1559 -0
- package/src/Collection.ts +653 -0
- package/src/Controller.md +306 -0
- package/src/Controller.test.ts +380 -0
- package/src/Controller.ts +90 -0
- package/src/EventBus.md +308 -0
- package/src/EventBus.test.ts +295 -0
- package/src/EventBus.ts +110 -0
- package/src/Feed.md +218 -0
- package/src/Feed.test.ts +442 -0
- package/src/Feed.ts +101 -0
- package/src/Model.md +524 -0
- package/src/Model.test.ts +642 -0
- package/src/Model.ts +260 -0
- package/src/Pagination.md +168 -0
- package/src/Pagination.test.ts +244 -0
- package/src/Pagination.ts +92 -0
- package/src/Pending.md +380 -0
- package/src/Pending.test.ts +1719 -0
- package/src/Pending.ts +390 -0
- package/src/PersistentCollection.md +183 -0
- package/src/PersistentCollection.test.ts +649 -0
- package/src/PersistentCollection.ts +375 -0
- package/src/Resource.ViewModel.test.ts +503 -0
- package/src/Resource.md +239 -0
- package/src/Resource.test.ts +786 -0
- package/src/Resource.ts +231 -0
- package/src/Selection.md +155 -0
- package/src/Selection.test.ts +326 -0
- package/src/Selection.ts +117 -0
- package/src/Service.md +440 -0
- package/src/Service.test.ts +241 -0
- package/src/Service.ts +72 -0
- package/src/Sorting.md +170 -0
- package/src/Sorting.test.ts +334 -0
- package/src/Sorting.ts +135 -0
- package/src/Trackable.md +166 -0
- package/src/Trackable.test.ts +236 -0
- package/src/Trackable.ts +129 -0
- package/src/ViewModel.async.test.ts +813 -0
- package/src/ViewModel.derived.test.ts +1583 -0
- package/src/ViewModel.md +1111 -0
- package/src/ViewModel.test.ts +1236 -0
- package/src/ViewModel.ts +800 -0
- package/src/bindPublicMethods.test.ts +126 -0
- package/src/bindPublicMethods.ts +48 -0
- package/src/env.d.ts +5 -0
- package/src/errors.test.ts +155 -0
- package/src/errors.ts +133 -0
- package/src/index.ts +49 -0
- package/src/produceDraft.md +90 -0
- package/src/produceDraft.test.ts +394 -0
- package/src/produceDraft.ts +168 -0
- package/src/react/components/CardList.md +97 -0
- package/src/react/components/CardList.test.tsx +142 -0
- package/src/react/components/CardList.tsx +68 -0
- package/src/react/components/DataTable.md +179 -0
- package/src/react/components/DataTable.test.tsx +599 -0
- package/src/react/components/DataTable.tsx +267 -0
- package/src/react/components/InfiniteScroll.md +116 -0
- package/src/react/components/InfiniteScroll.test.tsx +218 -0
- package/src/react/components/InfiniteScroll.tsx +70 -0
- package/src/react/components/types.ts +90 -0
- package/src/react/derived.test.tsx +261 -0
- package/src/react/guards.ts +24 -0
- package/src/react/index.ts +40 -0
- package/src/react/provider.test.tsx +143 -0
- package/src/react/provider.tsx +55 -0
- package/src/react/strict-mode.test.tsx +266 -0
- package/src/react/types.ts +25 -0
- package/src/react/use-event-bus.md +214 -0
- package/src/react/use-event-bus.test.tsx +168 -0
- package/src/react/use-event-bus.ts +40 -0
- package/src/react/use-instance.md +204 -0
- package/src/react/use-instance.test.tsx +350 -0
- package/src/react/use-instance.ts +60 -0
- package/src/react/use-local.md +457 -0
- package/src/react/use-local.rapid-remount.test.tsx +503 -0
- package/src/react/use-local.test.tsx +692 -0
- package/src/react/use-local.ts +165 -0
- package/src/react/use-model.md +364 -0
- package/src/react/use-model.test.tsx +394 -0
- package/src/react/use-model.ts +161 -0
- package/src/react/use-singleton.md +415 -0
- package/src/react/use-singleton.test.tsx +296 -0
- package/src/react/use-singleton.ts +69 -0
- package/src/react/use-subscribe-only.ts +39 -0
- package/src/react/use-teardown.md +169 -0
- package/src/react/use-teardown.test.tsx +86 -0
- package/src/react/use-teardown.ts +27 -0
- package/src/react-native/NativeCollection.test.ts +250 -0
- package/src/react-native/NativeCollection.ts +138 -0
- package/src/react-native/index.ts +1 -0
- package/src/singleton.md +310 -0
- package/src/singleton.test.ts +204 -0
- package/src/singleton.ts +70 -0
- package/src/types.ts +70 -0
- package/src/walkPrototypeChain.ts +22 -0
- package/src/web/IndexedDBCollection.test.ts +235 -0
- package/src/web/IndexedDBCollection.ts +66 -0
- package/src/web/WebStorageCollection.test.ts +214 -0
- package/src/web/WebStorageCollection.ts +116 -0
- package/src/web/idb.ts +184 -0
- package/src/web/index.ts +2 -0
- package/src/wrapAsyncMethods.ts +249 -0
package/src/Selection.ts
ADDED
|
@@ -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
|
+
```
|