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
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Trackable } from './Trackable';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Page-based pagination state manager with array slicing pipeline.
|
|
5
|
+
* Tracks current page and page size, provides navigation helpers.
|
|
6
|
+
* Subscribable — auto-tracked when used as a ViewModel property.
|
|
7
|
+
*/
|
|
8
|
+
export class Pagination extends Trackable {
|
|
9
|
+
private _page: number = 1;
|
|
10
|
+
private _pageSize: number;
|
|
11
|
+
|
|
12
|
+
constructor(options?: { pageSize?: number }) {
|
|
13
|
+
super();
|
|
14
|
+
this._pageSize = options?.pageSize ?? 10;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ── Readable state ──
|
|
18
|
+
|
|
19
|
+
/** Current page number (1-based). */
|
|
20
|
+
get page(): number {
|
|
21
|
+
return this._page;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Number of items per page. */
|
|
25
|
+
get pageSize(): number {
|
|
26
|
+
return this._pageSize;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── Derived (require total) ──
|
|
30
|
+
|
|
31
|
+
/** Total number of pages for the given item count. */
|
|
32
|
+
pageCount(total: number): number {
|
|
33
|
+
return Math.max(1, Math.ceil(total / this._pageSize));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Whether there is a next page available. */
|
|
37
|
+
hasNext(total: number): boolean {
|
|
38
|
+
return this._page < this.pageCount(total);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Whether there is a previous page available. */
|
|
42
|
+
hasPrev(): boolean {
|
|
43
|
+
return this._page > 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Actions ──
|
|
47
|
+
|
|
48
|
+
/** Navigate to a specific page (clamped to >= 1). */
|
|
49
|
+
setPage(page: number): void {
|
|
50
|
+
const clamped = Math.max(1, Math.floor(page));
|
|
51
|
+
if (clamped === this._page) return;
|
|
52
|
+
this._page = clamped;
|
|
53
|
+
this.notify();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Change the page size and reset to page 1. */
|
|
57
|
+
setPageSize(size: number): void {
|
|
58
|
+
if (size < 1) return;
|
|
59
|
+
this._pageSize = size;
|
|
60
|
+
this._page = 1;
|
|
61
|
+
this.notify();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Advance to the next page. */
|
|
65
|
+
nextPage(): void {
|
|
66
|
+
this._page++;
|
|
67
|
+
this.notify();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Go back to the previous page. No-op if already on page 1. */
|
|
71
|
+
prevPage(): void {
|
|
72
|
+
if (this._page > 1) {
|
|
73
|
+
this._page--;
|
|
74
|
+
this.notify();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Reset to page 1. */
|
|
79
|
+
reset(): void {
|
|
80
|
+
if (this._page === 1) return;
|
|
81
|
+
this._page = 1;
|
|
82
|
+
this.notify();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Pipeline ──
|
|
86
|
+
|
|
87
|
+
/** Slice an array to the current page window. Returns the page subset. */
|
|
88
|
+
apply<T>(items: T[]): T[] {
|
|
89
|
+
const start = (this._page - 1) * this._pageSize;
|
|
90
|
+
return items.slice(start, start + this._pageSize);
|
|
91
|
+
}
|
|
92
|
+
}
|
package/src/Pending.md
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
# Pending
|
|
2
|
+
|
|
3
|
+
Per-item operation queue with retry and status tracking. Manages optimistic update lifecycle with exponential backoff on transient errors. Extends [`Trackable`](./Trackable.md) — subscribable, disposable, and auto-bound.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## When to Use
|
|
8
|
+
|
|
9
|
+
Use Pending as a Resource property to track per-item server operations that may fail transiently. Pending provides:
|
|
10
|
+
|
|
11
|
+
- **Per-item status indicators** — show users which items are saving, retrying, or failed
|
|
12
|
+
- **Automatic retry with backoff** — network errors, timeouts, and 5xx errors retry automatically
|
|
13
|
+
- **Error classification** — 4xx errors (auth, validation) fail immediately without retry
|
|
14
|
+
- **Survive unmount** — lives on the singleton Resource, not the component-scoped ViewModel
|
|
15
|
+
- **Typed metadata** — attach display context (names, previews) to operations for rich UIs
|
|
16
|
+
|
|
17
|
+
For simple fire-and-forget operations where you don't need per-item status, use standard async tracking instead.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Creating an Instance
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
import { Pending } from 'mvc-kit';
|
|
25
|
+
|
|
26
|
+
// String keys (default)
|
|
27
|
+
readonly pending = new Pending<string>();
|
|
28
|
+
|
|
29
|
+
// Numeric keys
|
|
30
|
+
readonly pending = new Pending<number>();
|
|
31
|
+
|
|
32
|
+
// With typed metadata for UI display
|
|
33
|
+
interface SendMeta { recipientName: string; preview: string }
|
|
34
|
+
readonly pending = new Pending<string, SendMeta>();
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Custom Configuration via Subclass
|
|
38
|
+
|
|
39
|
+
Override static properties (Channel pattern):
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
class AggressivePending extends Pending<string> {
|
|
43
|
+
static override MAX_RETRIES = 10;
|
|
44
|
+
static override RETRY_BASE = 500;
|
|
45
|
+
static override RETRY_MAX = 60000;
|
|
46
|
+
static override RETRY_FACTOR = 1.5;
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## API
|
|
53
|
+
|
|
54
|
+
### Readable State
|
|
55
|
+
|
|
56
|
+
#### `getStatus(id: K): PendingOperation<Meta> | null`
|
|
57
|
+
|
|
58
|
+
Frozen snapshot of the operation's current state. Returns `null` if no operation exists for the given ID.
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
interface PendingOperation<Meta = unknown> {
|
|
62
|
+
readonly status: 'active' | 'retrying' | 'failed';
|
|
63
|
+
readonly operation: string;
|
|
64
|
+
readonly attempts: number;
|
|
65
|
+
readonly maxRetries: number;
|
|
66
|
+
readonly error: string | null;
|
|
67
|
+
readonly errorCode: AppError['code'] | null;
|
|
68
|
+
readonly nextRetryAt: number | null;
|
|
69
|
+
readonly createdAt: number;
|
|
70
|
+
readonly meta: Meta | null;
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The `meta` field contains the metadata passed to `enqueue()`, or `null` if none was provided.
|
|
75
|
+
|
|
76
|
+
#### `has(id: K): boolean`
|
|
77
|
+
|
|
78
|
+
Whether an operation exists for the given ID.
|
|
79
|
+
|
|
80
|
+
#### `count: number`
|
|
81
|
+
|
|
82
|
+
Total number of operations (all statuses).
|
|
83
|
+
|
|
84
|
+
#### `hasPending: boolean`
|
|
85
|
+
|
|
86
|
+
Whether any operations are in-flight (active or retrying). Does **not** include failed operations — use `hasFailed` for those.
|
|
87
|
+
|
|
88
|
+
#### `hasFailed: boolean`
|
|
89
|
+
|
|
90
|
+
Whether any operations are in `'failed'` status.
|
|
91
|
+
|
|
92
|
+
#### `failedCount: number`
|
|
93
|
+
|
|
94
|
+
Number of operations in `'failed'` status. Useful for rendering "N operations failed" banners.
|
|
95
|
+
|
|
96
|
+
#### `entries: readonly PendingEntry<K, Meta>[]`
|
|
97
|
+
|
|
98
|
+
All operations as a frozen array. Each entry extends `PendingOperation<Meta>` with an `id: K` field. Cached until the next mutation — reference-stable between reads.
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
interface PendingEntry<K extends string | number, Meta = unknown>
|
|
102
|
+
extends PendingOperation<Meta> {
|
|
103
|
+
readonly id: K;
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Use for rendering lists of pending/failed operations (e.g., in modals or status panels):
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
for (const entry of vm.pending.entries) {
|
|
111
|
+
entry.id; // K
|
|
112
|
+
entry.status; // 'active' | 'retrying' | 'failed'
|
|
113
|
+
entry.operation; // string
|
|
114
|
+
entry.meta; // Meta | null
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Core API
|
|
119
|
+
|
|
120
|
+
#### `enqueue(id: K, operation: string, execute: (signal: AbortSignal) => Promise<void>, meta?: Meta): void`
|
|
121
|
+
|
|
122
|
+
Fire-and-forget. Enqueues an operation and starts processing via microtask. If the same ID already has a pending operation, it is superseded (previous signal aborted).
|
|
123
|
+
|
|
124
|
+
```typescript
|
|
125
|
+
// In a Resource method:
|
|
126
|
+
async deleteItem(id: string) {
|
|
127
|
+
this.optimistic(() => this.remove(id));
|
|
128
|
+
this.pending.enqueue(id, 'delete', async (signal) => {
|
|
129
|
+
await api.deleteItem(id, signal);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// With metadata for UI display:
|
|
134
|
+
enqueueSend(tempId: string, text: string, recipientName: string) {
|
|
135
|
+
this.pending.enqueue(tempId, 'send', async (signal) => {
|
|
136
|
+
await api.sendMessage(tempId, text, signal);
|
|
137
|
+
}, { recipientName, preview: text.slice(0, 50) });
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The `signal` parameter is owned by Pending — do **not** pass `this.disposeSignal` from a ViewModel.
|
|
142
|
+
|
|
143
|
+
### Controls
|
|
144
|
+
|
|
145
|
+
#### `retry(id: K): void`
|
|
146
|
+
|
|
147
|
+
Re-process a failed operation. Resets attempts to 0. No-op if the operation is not in `'failed'` status.
|
|
148
|
+
|
|
149
|
+
#### `retryAll(): void`
|
|
150
|
+
|
|
151
|
+
Retry all failed operations.
|
|
152
|
+
|
|
153
|
+
#### `cancel(id: K): void`
|
|
154
|
+
|
|
155
|
+
Abort an in-flight operation's signal, clear its retry timer, and remove it. Works on any status.
|
|
156
|
+
|
|
157
|
+
#### `cancelAll(): void`
|
|
158
|
+
|
|
159
|
+
Cancel all operations.
|
|
160
|
+
|
|
161
|
+
#### `dismiss(id: K): void`
|
|
162
|
+
|
|
163
|
+
Remove a failed operation without retrying. No-op if the operation is not in `'failed'` status. Use for "dismiss" buttons in failure UIs.
|
|
164
|
+
|
|
165
|
+
#### `dismissAll(): void`
|
|
166
|
+
|
|
167
|
+
Remove all failed operations without retrying. Single notification. Does not affect active or retrying operations.
|
|
168
|
+
|
|
169
|
+
### Hooks
|
|
170
|
+
|
|
171
|
+
Override in a subclass for side effects:
|
|
172
|
+
|
|
173
|
+
#### `protected isRetryable(error: unknown): boolean`
|
|
174
|
+
|
|
175
|
+
Determines whether an error should trigger retry. Default: retries on `network`, `timeout`, and `server_error` codes.
|
|
176
|
+
|
|
177
|
+
#### `protected onConfirmed?(id: K, operation: string): void`
|
|
178
|
+
|
|
179
|
+
Called when an operation succeeds.
|
|
180
|
+
|
|
181
|
+
#### `protected onFailed?(id: K, operation: string, error: unknown): void`
|
|
182
|
+
|
|
183
|
+
Called when an operation fails permanently (non-retryable or max retries exceeded).
|
|
184
|
+
|
|
185
|
+
### Subscribable Interface
|
|
186
|
+
|
|
187
|
+
#### `subscribe(cb: () => void): () => void`
|
|
188
|
+
|
|
189
|
+
Subscribe to state changes. Returns an unsubscribe function. Auto-tracked by ViewModel when assigned as a property.
|
|
190
|
+
|
|
191
|
+
### Lifecycle
|
|
192
|
+
|
|
193
|
+
#### `disposed: boolean`
|
|
194
|
+
|
|
195
|
+
Whether this instance has been disposed.
|
|
196
|
+
|
|
197
|
+
#### `dispose(): void`
|
|
198
|
+
|
|
199
|
+
Cancels all operations, clears all listeners. Called automatically when the owning Resource is torn down.
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## ViewModel Integration
|
|
204
|
+
|
|
205
|
+
Pending lives on the **singleton Resource** so operations survive component unmount. The ViewModel holds a reference for auto-tracking:
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
class ItemsResource extends Resource<Item> {
|
|
209
|
+
readonly pending = new Pending<string>();
|
|
210
|
+
|
|
211
|
+
async deleteItem(id: string) {
|
|
212
|
+
this.optimistic(() => this.remove(id));
|
|
213
|
+
this.pending.enqueue(id, 'delete', async (signal) => {
|
|
214
|
+
await api.deleteItem(id, signal);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
protected override onDispose() {
|
|
219
|
+
this.pending.dispose();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
class ItemsVM extends ViewModel<{ filter: string }> {
|
|
224
|
+
private resource = singleton(ItemsResource);
|
|
225
|
+
|
|
226
|
+
// Auto-tracked: getter recomputes when pending status changes
|
|
227
|
+
get pending() { return this.resource.pending; }
|
|
228
|
+
get hasFailed() { return this.resource.pending.hasFailed; }
|
|
229
|
+
get failedCount() { return this.resource.pending.failedCount; }
|
|
230
|
+
|
|
231
|
+
get items() {
|
|
232
|
+
return this.resource.items.filter(i =>
|
|
233
|
+
i.name.includes(this.state.filter),
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
deleteItem(id: string) {
|
|
238
|
+
this.resource.deleteItem(id);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
retryFailed() {
|
|
242
|
+
this.resource.pending.retryAll();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
dismissFailed() {
|
|
246
|
+
this.resource.pending.dismissAll();
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### Signal Ownership
|
|
252
|
+
|
|
253
|
+
The `signal` passed to `enqueue`'s callback is owned by Pending. It is aborted when:
|
|
254
|
+
- The operation is superseded (same ID enqueued again)
|
|
255
|
+
- The operation is cancelled via `cancel()` / `cancelAll()`
|
|
256
|
+
- The Pending instance is disposed
|
|
257
|
+
|
|
258
|
+
Do **not** pass `vm.disposeSignal` — that would abort the operation when the component unmounts, defeating the purpose of singleton-scoped resilience.
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## React Usage
|
|
263
|
+
|
|
264
|
+
```tsx
|
|
265
|
+
function ItemRow({ item, vm }: { item: Item; vm: ItemsVM }) {
|
|
266
|
+
const status = vm.pending.getStatus(item.id);
|
|
267
|
+
|
|
268
|
+
return (
|
|
269
|
+
<tr>
|
|
270
|
+
<td>{item.name}</td>
|
|
271
|
+
<td>
|
|
272
|
+
{status?.status === 'retrying' && <span>Retrying...</span>}
|
|
273
|
+
{status?.status === 'failed' && (
|
|
274
|
+
<>
|
|
275
|
+
<span>Failed: {status.error}</span>
|
|
276
|
+
<button onClick={() => vm.pending.retry(item.id)}>Retry</button>
|
|
277
|
+
<button onClick={() => vm.pending.dismiss(item.id)}>Dismiss</button>
|
|
278
|
+
</>
|
|
279
|
+
)}
|
|
280
|
+
</td>
|
|
281
|
+
</tr>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function FailedBanner({ vm }: { vm: ItemsVM }) {
|
|
286
|
+
if (!vm.hasFailed) return null;
|
|
287
|
+
return (
|
|
288
|
+
<div>
|
|
289
|
+
{vm.failedCount} operation(s) failed.
|
|
290
|
+
<button onClick={() => vm.retryFailed()}>Retry all</button>
|
|
291
|
+
<button onClick={() => vm.dismissFailed()}>Dismiss all</button>
|
|
292
|
+
</div>
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
### Pending Operations Modal with Meta
|
|
298
|
+
|
|
299
|
+
Use `entries` and typed metadata to render rich pending operations UIs:
|
|
300
|
+
|
|
301
|
+
```typescript
|
|
302
|
+
// Resource with typed metadata
|
|
303
|
+
interface SendMeta { recipientName: string; preview: string }
|
|
304
|
+
|
|
305
|
+
class MessagesResource extends Resource<MessageState> {
|
|
306
|
+
readonly pending = new Pending<string, SendMeta>();
|
|
307
|
+
|
|
308
|
+
enqueueSend(tempId: string, convId: string, text: string, recipientName: string) {
|
|
309
|
+
this.pending.enqueue(tempId, 'send', async (signal) => {
|
|
310
|
+
await api.sendMessage(convId, text, signal);
|
|
311
|
+
}, { recipientName, preview: text.slice(0, 50) });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
```tsx
|
|
317
|
+
// Modal listing all pending/failed operations
|
|
318
|
+
function PendingModal({ pending }: { pending: Pending<string, SendMeta> }) {
|
|
319
|
+
return (
|
|
320
|
+
<ul>
|
|
321
|
+
{pending.entries.map(entry => (
|
|
322
|
+
<li key={entry.id}>
|
|
323
|
+
<strong>To: {entry.meta?.recipientName}</strong>
|
|
324
|
+
<span>{entry.meta?.preview}</span>
|
|
325
|
+
<span>{entry.status} (attempt {entry.attempts}/{entry.maxRetries})</span>
|
|
326
|
+
{entry.status === 'failed' && (
|
|
327
|
+
<>
|
|
328
|
+
<button onClick={() => pending.retry(entry.id)}>Retry</button>
|
|
329
|
+
<button onClick={() => pending.dismiss(entry.id)}>Dismiss</button>
|
|
330
|
+
</>
|
|
331
|
+
)}
|
|
332
|
+
</li>
|
|
333
|
+
))}
|
|
334
|
+
</ul>
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
---
|
|
340
|
+
|
|
341
|
+
## Error Classification
|
|
342
|
+
|
|
343
|
+
Pending uses `classifyError()` from `src/errors.ts` to determine retry behavior:
|
|
344
|
+
|
|
345
|
+
| Error Code | Retries? | Examples |
|
|
346
|
+
|---|---|---|
|
|
347
|
+
| `network` | Yes | `TypeError: Failed to fetch` |
|
|
348
|
+
| `timeout` | Yes | `TimeoutError` |
|
|
349
|
+
| `server_error` | Yes | HTTP 500, 502, 503 |
|
|
350
|
+
| `unauthorized` | No | HTTP 401 |
|
|
351
|
+
| `forbidden` | No | HTTP 403 |
|
|
352
|
+
| `not_found` | No | HTTP 404 |
|
|
353
|
+
| `validation` | No | HTTP 422 |
|
|
354
|
+
| `rate_limited` | No | HTTP 429 |
|
|
355
|
+
| `unknown` | No | Unrecognized errors |
|
|
356
|
+
| `abort` | Silent remove | `AbortError` |
|
|
357
|
+
|
|
358
|
+
Override `isRetryable()` in a subclass to customize (e.g., to retry rate-limited requests).
|
|
359
|
+
|
|
360
|
+
---
|
|
361
|
+
|
|
362
|
+
## Configuration
|
|
363
|
+
|
|
364
|
+
| Static Property | Default | Description |
|
|
365
|
+
|---|---|---|
|
|
366
|
+
| `MAX_RETRIES` | `5` | Maximum retry attempts before marking as failed |
|
|
367
|
+
| `RETRY_BASE` | `1000` | Base delay (ms) for exponential backoff |
|
|
368
|
+
| `RETRY_MAX` | `30000` | Maximum delay cap (ms) |
|
|
369
|
+
| `RETRY_FACTOR` | `2` | Exponential multiplier |
|
|
370
|
+
|
|
371
|
+
Backoff formula (same as Channel): `Math.random() * Math.min(BASE * FACTOR^attempt, MAX)`
|
|
372
|
+
|
|
373
|
+
## Method Binding
|
|
374
|
+
|
|
375
|
+
All public methods are auto-bound in the constructor. You can pass them point-free as callbacks without losing `this` context:
|
|
376
|
+
|
|
377
|
+
```tsx
|
|
378
|
+
const { retryAll, dismissAll } = pending;
|
|
379
|
+
<button onClick={retryAll}>Retry All</button> // point-free works
|
|
380
|
+
```
|